@@ -39,10 +55,20 @@ const nav = [
{{ n.label }}
-
v20260726-0130
+
-
+
+
+ {{ adminAuth.identity.value?.username }}
+ {{ adminAuth.identity.value?.role }}
+
+
+
diff --git a/admin-web/src/api/index.ts b/admin-web/src/api/index.ts
index 0749e1f..ae0fbd8 100644
--- a/admin-web/src/api/index.ts
+++ b/admin-web/src/api/index.ts
@@ -1,18 +1,29 @@
-import axios from 'axios'
-import { message } from 'ant-design-vue'
-
-const ADMIN_KEY = localStorage.getItem('miaoji_admin_key') || ''
+import axios from 'axios'
+
+let csrfToken = ''
+
+export function setCsrfToken(value: string) {
+ csrfToken = value
+}
const http = axios.create({
- baseURL: import.meta.env.VITE_API_BASE || '',
- headers: { 'X-Admin-Key': ADMIN_KEY },
-})
+ baseURL: import.meta.env.VITE_API_BASE || '',
+ withCredentials: true,
+})
+
+http.interceptors.request.use(config => {
+ const method = config.method?.toLowerCase()
+ if (csrfToken && method && !['get', 'head', 'options'].includes(method)) {
+ config.headers.set('X-CSRF-Token', csrfToken)
+ }
+ return config
+})
http.interceptors.response.use(
r => r,
err => {
- if (err.response?.status === 401) {
- message.error('管理密钥无效,请在 localStorage 设置 miaoji_admin_key')
+ if (err.response?.status === 401) {
+ window.dispatchEvent(new CustomEvent('admin-session-expired'))
}
return Promise.reject(err)
}
@@ -55,6 +66,12 @@ export const api = {
pushDevices: (params: { search?: string; limit?: number }) => http.get('/api/admin/push/devices', { params }).then(r => r.data),
testPush: (data: any) => http.post('/api/admin/push/test', data).then(r => r.data),
pushHealth: () => http.get('/api/admin/push/health').then(r => r.data),
+ adminAccounts: () => http.get('/api/admin/security/accounts').then(r => r.data),
+ createAdminAccount: (data: any) => http.post('/api/admin/security/accounts', data).then(r => r.data),
+ updateAdminAccount: (id: number, data: any) => http.put(`/api/admin/security/accounts/${id}`, data).then(r => r.data),
+ resetAdminPassword: (id: number, password: string) => http.put(`/api/admin/security/accounts/${id}/password`, { password }),
+ revokeAdminSessions: (id: number) => http.post(`/api/admin/security/accounts/${id}/revoke-sessions`),
+ auditLogs: (params: any) => http.get('/api/admin/security/audit', { params }).then(r => r.data),
}
export default http
diff --git a/admin-web/src/auth.ts b/admin-web/src/auth.ts
new file mode 100644
index 0000000..387d81a
--- /dev/null
+++ b/admin-web/src/auth.ts
@@ -0,0 +1,66 @@
+import { computed, ref } from 'vue'
+import http, { setCsrfToken } from './api'
+
+export interface AdminIdentity {
+ id: number
+ username: string
+ role: 'super_admin' | 'operator' | 'viewer'
+ mustChangePassword: boolean
+ csrfToken: string
+}
+
+const identity = ref
(null)
+let loaded = false
+let loading: Promise | null = null
+
+function apply(value: AdminIdentity | null) {
+ identity.value = value
+ setCsrfToken(value?.csrfToken || '')
+}
+
+export const adminAuth = {
+ identity,
+ isAuthenticated: computed(() => identity.value !== null),
+ async ensure() {
+ if (loaded) return identity.value !== null
+ if (loading) return loading
+ loading = http.get('/api/admin/auth/me')
+ .then(response => {
+ apply(response.data as AdminIdentity)
+ loaded = true
+ return true
+ })
+ .catch(() => {
+ apply(null)
+ loaded = true
+ return false
+ })
+ .finally(() => { loading = null })
+ return loading
+ },
+ async login(username: string, password: string) {
+ const response = await http.post('/api/admin/auth/login', { username, password })
+ apply(response.data as AdminIdentity)
+ loaded = true
+ return identity.value!
+ },
+ async logout() {
+ try {
+ await http.post('/api/admin/auth/logout')
+ } finally {
+ apply(null)
+ loaded = true
+ }
+ },
+ async changePassword(currentPassword: string, newPassword: string) {
+ const response = await http.put('/api/admin/auth/password', { currentPassword, newPassword })
+ apply(response.data as AdminIdentity)
+ return identity.value!
+ },
+ clear() {
+ apply(null)
+ loaded = true
+ },
+}
+
+window.addEventListener('admin-session-expired', () => adminAuth.clear())
diff --git a/admin-web/src/router/index.ts b/admin-web/src/router/index.ts
index 035b62b..b4d788e 100644
--- a/admin-web/src/router/index.ts
+++ b/admin-web/src/router/index.ts
@@ -1,8 +1,11 @@
import { createRouter, createWebHashHistory } from 'vue-router'
+import { adminAuth } from '../auth'
const router = createRouter({
history: createWebHashHistory(),
routes: [
+ { path: '/login', name: 'Login', component: () => import('../views/Login.vue'), meta: { public: true } },
+ { path: '/change-password', name: 'ChangePassword', component: () => import('../views/ChangePassword.vue') },
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
{ path: '/settings', name: 'Settings', component: () => import('../views/Settings.vue') },
@@ -13,7 +16,24 @@ const router = createRouter({
{ path: '/stickers', name: 'Stickers', component: () => import('../views/Stickers.vue') },
{ path: '/users', name: 'Users', component: () => import('../views/Users.vue') },
{ path: '/push', name: 'PushCampaigns', component: () => import('../views/PushCampaigns.vue') },
+ { path: '/admin-accounts', name: 'AdminAccounts', component: () => import('../views/AdminAccounts.vue'), meta: { role: 'super_admin' } },
+ { path: '/audit', name: 'Audit', component: () => import('../views/Audit.vue'), meta: { role: 'super_admin' } },
],
})
+router.beforeEach(async to => {
+ if (to.meta.public) {
+ if (to.name === 'Login' && await adminAuth.ensure()) {
+ return adminAuth.identity.value?.mustChangePassword ? '/change-password' : '/dashboard'
+ }
+ return true
+ }
+ if (!await adminAuth.ensure()) return { name: 'Login', query: { redirect: to.fullPath } }
+ const user = adminAuth.identity.value!
+ if (user.mustChangePassword && to.name !== 'ChangePassword') return { name: 'ChangePassword' }
+ if (!user.mustChangePassword && to.name === 'ChangePassword') return { name: 'Dashboard' }
+ if (to.meta.role && to.meta.role !== user.role) return { name: 'Dashboard' }
+ return true
+})
+
export default router
diff --git a/admin-web/src/views/AdminAccounts.vue b/admin-web/src/views/AdminAccounts.vue
new file mode 100644
index 0000000..3fe2021
--- /dev/null
+++ b/admin-web/src/views/AdminAccounts.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+ update(record, { role })">
+ super_admin
+ operator
+ viewer
+
+
+
+
+
+ update(record, { isActive })" />
+
+
+
+
+ {{ formatShanghaiDate(record.lastLoginAt) }}
+
+
+
+
+ 重置密码
+ 撤销会话
+
+
+
+
+
+
+
+
+
+
+
+ operator
+ viewer
+ super_admin
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-web/src/views/Audit.vue b/admin-web/src/views/Audit.vue
new file mode 100644
index 0000000..b7e86dd
--- /dev/null
+++ b/admin-web/src/views/Audit.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+ {{ formatShanghaiDate(record.createdAt) }}
+
+
+
+ {{ record.statusCode }}
+
+
+
+
+
+
+
diff --git a/admin-web/src/views/ChangePassword.vue b/admin-web/src/views/ChangePassword.vue
new file mode 100644
index 0000000..3d6e876
--- /dev/null
+++ b/admin-web/src/views/ChangePassword.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+ 记之 Admin
+ 修改初始密码
+
+
+
+
+
+
+
+
+
+
+
+ 退出登录
+ 保存密码
+
+
+
+
+
+
+
diff --git a/admin-web/src/views/Login.vue b/admin-web/src/views/Login.vue
new file mode 100644
index 0000000..10a4f7d
--- /dev/null
+++ b/admin-web/src/views/Login.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+ 记之 Admin
+ 管理后台登录
+
+
+
+
+
+
+
+
+ 登录
+
+
+
+
+
+
diff --git a/backend/MiaoJiZhang.Api.Tests/ApiIntegrationTests.cs b/backend/MiaoJiZhang.Api.Tests/ApiIntegrationTests.cs
index 56393ae..01a76f0 100644
--- a/backend/MiaoJiZhang.Api.Tests/ApiIntegrationTests.cs
+++ b/backend/MiaoJiZhang.Api.Tests/ApiIntegrationTests.cs
@@ -21,8 +21,11 @@ public sealed class ApiCollection : ICollectionFixture
public const string Name = "api";
}
-public sealed class ApiFixture : IAsyncLifetime
-{
+public sealed class ApiFixture : IAsyncLifetime
+{
+ private const string AdminUsername = "test_admin";
+ private const string BootstrapPassword = "test-bootstrap-password-123";
+ private const string AdminPassword = "test-permanent-password-456";
private readonly MySqlContainer _database = new MySqlBuilder("mysql:8.4")
.WithDatabase("miaoji_test")
.WithUsername("miaoji_test")
@@ -41,9 +44,9 @@ public sealed class ApiFixture : IAsyncLifetime
Environment.SetEnvironmentVariable(
"Jwt__Secret",
"test-only-jwt-secret-at-least-thirty-two-characters");
- Environment.SetEnvironmentVariable(
- "Admin__Key",
- "test-only-admin-key-at-least-24-characters");
+ Environment.SetEnvironmentVariable("Admin__BootstrapUsername", AdminUsername);
+ Environment.SetEnvironmentVariable("Admin__BootstrapPassword", BootstrapPassword);
+ Environment.SetEnvironmentVariable("Admin__CookieSecure", "false");
Environment.SetEnvironmentVariable(
"Push__TokenEncryptionKey",
Convert.ToBase64String(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray()));
@@ -60,8 +63,9 @@ public sealed class ApiFixture : IAsyncLifetime
});
});
using var client = Factory.CreateClient();
- var ping = await client.GetAsync("/api/ping");
- ping.EnsureSuccessStatusCode();
+ var ping = await client.GetAsync("/api/ping");
+ ping.EnsureSuccessStatusCode();
+ await BootstrapAdminAsync();
}
public async Task DisposeAsync()
@@ -70,12 +74,14 @@ public sealed class ApiFixture : IAsyncLifetime
await _database.DisposeAsync();
Environment.SetEnvironmentVariable("ConnectionStrings__Default", null);
Environment.SetEnvironmentVariable("Jwt__Secret", null);
- Environment.SetEnvironmentVariable("Admin__Key", null);
+ Environment.SetEnvironmentVariable("Admin__BootstrapUsername", null);
+ Environment.SetEnvironmentVariable("Admin__BootstrapPassword", null);
+ Environment.SetEnvironmentVariable("Admin__CookieSecure", null);
Environment.SetEnvironmentVariable("Push__TokenEncryptionKey", null);
Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", null);
}
- public async Task RegisterAsync(string username)
+ public async Task RegisterAsync(string username)
{
var client = Factory.CreateClient();
var response = await client.PostAsJsonAsync(
@@ -86,8 +92,39 @@ public sealed class ApiFixture : IAsyncLifetime
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
payload.GetProperty("token").GetString());
- return client;
- }
+ return client;
+ }
+
+ public async Task AdminAsync()
+ {
+ var client = Factory.CreateClient();
+ var login = await client.PostAsJsonAsync(
+ "/api/admin/auth/login",
+ new { username = AdminUsername, password = AdminPassword });
+ login.EnsureSuccessStatusCode();
+ var payload = await login.Content.ReadFromJsonAsync();
+ client.DefaultRequestHeaders.Add(
+ AdminSessionService.CsrfHeader,
+ payload.GetProperty("csrfToken").GetString());
+ return client;
+ }
+
+ private async Task BootstrapAdminAsync()
+ {
+ using var client = Factory.CreateClient();
+ var login = await client.PostAsJsonAsync(
+ "/api/admin/auth/login",
+ new { username = AdminUsername, password = BootstrapPassword });
+ login.EnsureSuccessStatusCode();
+ var payload = await login.Content.ReadFromJsonAsync();
+ client.DefaultRequestHeaders.Add(
+ AdminSessionService.CsrfHeader,
+ payload.GetProperty("csrfToken").GetString());
+ var changed = await client.PutAsJsonAsync(
+ "/api/admin/auth/password",
+ new { currentPassword = BootstrapPassword, newPassword = AdminPassword });
+ changed.EnsureSuccessStatusCode();
+ }
}
[Collection(ApiCollection.Name)]
@@ -324,10 +361,7 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
var profile = await client.GetFromJsonAsync("/api/users/me");
var userId = profile.GetProperty("userId").GetInt64();
- using var admin = fixture.Factory.CreateClient();
- admin.DefaultRequestHeaders.Add(
- "X-Admin-Key",
- "test-only-admin-key-at-least-24-characters");
+ using var admin = await fixture.AdminAsync();
var update = await admin.PutAsJsonAsync(
$"/api/admin/users/{userId}/ai-quota",
new { limit = 1, period = "week", resetUsage = true });
@@ -419,39 +453,54 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
candidateId = "transfer-1",
clientRequestId = "recognition-wechat-flow-1",
categoryId,
- type = "expense",
+ type = "transfer",
amount = 20m,
note = "转账给张三",
paymentMethod = "微信",
occurredAt = "2026-07-25T10:00:01+08:00",
source = "recognition_ai",
sourceText = "微信转账成功",
+ transferDirection = "out",
+ counterparty = "张三",
+ provider = "wechat",
+ providerTransactionId = "wx-transfer-order-1",
+ recognitionOccurrenceId = "wx-transfer-flow-1",
},
new
{
candidateId = "transfer-2",
clientRequestId = "recognition-wechat-flow-2",
categoryId,
- type = "expense",
+ type = "transfer",
amount = 20m,
note = "转账给张三",
paymentMethod = "微信",
occurredAt = "2026-07-25T10:00:10+08:00",
source = "recognition_ai",
sourceText = "微信转账成功",
+ transferDirection = "out",
+ counterparty = "张三",
+ provider = "wechat",
+ providerTransactionId = "wx-transfer-order-2",
+ recognitionOccurrenceId = "wx-transfer-flow-2",
},
new
{
candidateId = "transfer-3",
clientRequestId = "recognition-wechat-flow-3",
categoryId,
- type = "expense",
+ type = "transfer",
amount = 30m,
note = "转账给张三",
paymentMethod = "微信",
occurredAt = "2026-07-25T10:00:20+08:00",
source = "recognition_ai",
sourceText = "微信转账成功",
+ transferDirection = "out",
+ counterparty = "张三",
+ provider = "wechat",
+ providerTransactionId = "wx-transfer-order-3",
+ recognitionOccurrenceId = "wx-transfer-flow-3",
},
},
};
@@ -476,6 +525,205 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
$"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
Assert.Equal(3, month.GetProperty("count").GetInt32());
Assert.Equal(70m, month.GetProperty("expense").GetDecimal());
+ Assert.All(
+ month.GetProperty("days")[0].GetProperty("items").EnumerateArray(),
+ item =>
+ {
+ Assert.Equal("transfer", item.GetProperty("type").GetString());
+ Assert.Equal("out", item.GetProperty("transferDirection").GetString());
+ Assert.Equal("张三", item.GetProperty("counterparty").GetString());
+ });
+ }
+
+ [Fact]
+ public async Task TransferDirections_AffectStatisticsAndBudgets_AndRecognitionIdsAreIdempotent()
+ {
+ using var client = await fixture.RegisterAsync("transfer_semantics");
+ var ledgers = await client.GetFromJsonAsync("/api/ledgers");
+ var ledgerId = ledgers[0].GetProperty("id").GetInt64();
+ var expenseCategories = await client.GetFromJsonAsync(
+ "/api/categories?type=expense");
+ var incomeCategories = await client.GetFromJsonAsync(
+ "/api/categories?type=income");
+ var expenseCategoryId = expenseCategories[0].GetProperty("id").GetInt64();
+ var incomeCategoryId = incomeCategories[0].GetProperty("id").GetInt64();
+
+ var totalBudget = await client.PutAsJsonAsync(
+ $"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
+ new { categoryId = (long?)null, amount = 100m });
+ totalBudget.EnsureSuccessStatusCode();
+ var categoryBudget = await client.PutAsJsonAsync(
+ $"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
+ new { categoryId = expenseCategoryId, amount = 80m });
+ categoryBudget.EnsureSuccessStatusCode();
+
+ var transferOut = new
+ {
+ ledgerId,
+ categoryId = expenseCategoryId,
+ type = "transfer",
+ transferDirection = "out",
+ counterparty = "李四",
+ amount = 40m,
+ occurredAt = "2026-07-25T11:00:00+08:00",
+ source = "accessibility",
+ clientRequestId = "wx-event-out-a",
+ provider = "wechat",
+ providerTransactionId = "wx-order-out-001",
+ recognitionOccurrenceId = "wx-occurrence-out-001",
+ };
+ var outResponse = await client.PostAsJsonAsync("/api/transactions", transferOut);
+ outResponse.EnsureSuccessStatusCode();
+ var outTransaction = await outResponse.Content.ReadFromJsonAsync();
+
+ var providerRetry = await client.PostAsJsonAsync(
+ "/api/transactions",
+ new
+ {
+ transferOut.ledgerId,
+ transferOut.categoryId,
+ transferOut.type,
+ transferOut.transferDirection,
+ transferOut.counterparty,
+ transferOut.amount,
+ transferOut.occurredAt,
+ transferOut.source,
+ clientRequestId = "wx-event-out-b",
+ transferOut.provider,
+ transferOut.providerTransactionId,
+ recognitionOccurrenceId = "wx-occurrence-out-changed",
+ });
+ providerRetry.EnsureSuccessStatusCode();
+ var retried = await providerRetry.Content.ReadFromJsonAsync();
+ Assert.Equal(
+ outTransaction.GetProperty("id").GetInt64(),
+ retried.GetProperty("id").GetInt64());
+
+ var inResponse = await client.PostAsJsonAsync(
+ "/api/transactions",
+ new
+ {
+ ledgerId,
+ categoryId = incomeCategoryId,
+ type = "transfer",
+ transferDirection = "in",
+ counterparty = "李四",
+ amount = 75m,
+ occurredAt = "2026-07-25T11:05:00+08:00",
+ source = "accessibility",
+ clientRequestId = "wx-event-in-a",
+ recognitionOccurrenceId = "wx-occurrence-in-001",
+ });
+ inResponse.EnsureSuccessStatusCode();
+ var inTransaction = await inResponse.Content.ReadFromJsonAsync();
+ var occurrenceRetry = await client.PostAsJsonAsync(
+ "/api/transactions",
+ new
+ {
+ ledgerId,
+ categoryId = incomeCategoryId,
+ type = "transfer",
+ transferDirection = "in",
+ counterparty = "李四",
+ amount = 75m,
+ occurredAt = "2026-07-25T11:05:00+08:00",
+ source = "accessibility",
+ clientRequestId = "wx-event-in-b",
+ recognitionOccurrenceId = "wx-occurrence-in-001",
+ });
+ occurrenceRetry.EnsureSuccessStatusCode();
+ var occurrenceRetried = await occurrenceRetry.Content.ReadFromJsonAsync();
+ Assert.Equal(
+ inTransaction.GetProperty("id").GetInt64(),
+ occurrenceRetried.GetProperty("id").GetInt64());
+
+ var month = await client.GetFromJsonAsync(
+ $"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
+ Assert.Equal(2, month.GetProperty("count").GetInt32());
+ Assert.Equal(40m, month.GetProperty("expense").GetDecimal());
+ Assert.Equal(75m, month.GetProperty("income").GetDecimal());
+
+ var stats = await client.GetFromJsonAsync(
+ $"/api/transactions/stats?year=2026&month=7&ledgerId={ledgerId}");
+ Assert.Equal(40m, stats.GetProperty("totalExpense").GetDecimal());
+ Assert.Equal(75m, stats.GetProperty("totalIncome").GetDecimal());
+
+ var budgets = await client.GetFromJsonAsync(
+ $"/api/budgets?year=2026&month=7&ledgerId={ledgerId}");
+ Assert.Equal(40m, budgets.GetProperty("total").GetProperty("spent").GetDecimal());
+ var expenseBudget = Assert.Single(
+ budgets.GetProperty("categories").EnumerateArray(),
+ item => item.GetProperty("categoryId").GetInt64() == expenseCategoryId);
+ Assert.Equal(40m, expenseBudget.GetProperty("spent").GetDecimal());
+ }
+
+ [Fact]
+ public async Task AdminSessions_EnforceCsrfViewerRevocationAndAudit()
+ {
+ using var superAdmin = await fixture.AdminAsync();
+ var suffix = Guid.NewGuid().ToString("N")[..10];
+ var username = $"viewer_{suffix}";
+ const string initialPassword = "viewer-initial-password-123";
+ const string permanentPassword = "viewer-permanent-password-456";
+ var createdResponse = await superAdmin.PostAsJsonAsync(
+ "/api/admin/security/accounts",
+ new { username, password = initialPassword, role = "viewer" });
+ createdResponse.EnsureSuccessStatusCode();
+ var created = await createdResponse.Content.ReadFromJsonAsync();
+ var viewerId = created.GetProperty("id").GetInt64();
+
+ using var viewer = fixture.Factory.CreateClient();
+ var login = await viewer.PostAsJsonAsync(
+ "/api/admin/auth/login",
+ new { username, password = initialPassword });
+ login.EnsureSuccessStatusCode();
+ var loginPayload = await login.Content.ReadFromJsonAsync();
+ Assert.True(loginPayload.GetProperty("mustChangePassword").GetBoolean());
+
+ var missingCsrf = await viewer.PutAsJsonAsync(
+ "/api/admin/auth/password",
+ new { currentPassword = initialPassword, newPassword = permanentPassword });
+ Assert.Equal(HttpStatusCode.Unauthorized, missingCsrf.StatusCode);
+
+ viewer.DefaultRequestHeaders.Add(
+ AdminSessionService.CsrfHeader,
+ loginPayload.GetProperty("csrfToken").GetString());
+ var changed = await viewer.PutAsJsonAsync(
+ "/api/admin/auth/password",
+ new { currentPassword = initialPassword, newPassword = permanentPassword });
+ changed.EnsureSuccessStatusCode();
+ var changedPayload = await changed.Content.ReadFromJsonAsync();
+ viewer.DefaultRequestHeaders.Remove(AdminSessionService.CsrfHeader);
+ viewer.DefaultRequestHeaders.Add(
+ AdminSessionService.CsrfHeader,
+ changedPayload.GetProperty("csrfToken").GetString());
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ (await viewer.GetAsync("/api/admin/dashboard")).StatusCode);
+ var writeDenied = await viewer.PostAsJsonAsync(
+ "/api/admin/configs",
+ new { key = $"viewer.denied.{suffix}", value = "no" });
+ Assert.Equal(HttpStatusCode.Forbidden, writeDenied.StatusCode);
+
+ var revoked = await superAdmin.PostAsync(
+ $"/api/admin/security/accounts/{viewerId}/revoke-sessions",
+ null);
+ Assert.Equal(HttpStatusCode.NoContent, revoked.StatusCode);
+ Assert.Equal(
+ HttpStatusCode.Unauthorized,
+ (await viewer.GetAsync("/api/admin/dashboard")).StatusCode);
+
+ var audit = await superAdmin.GetFromJsonAsync(
+ $"/api/admin/security/audit?username={username}");
+ Assert.True(audit.GetProperty("total").GetInt32() >= 3);
+ var entries = audit.GetProperty("list").EnumerateArray().ToList();
+ Assert.Contains(entries, entry =>
+ entry.GetProperty("action").GetString() == "post.api.admin.auth.login" &&
+ entry.GetProperty("success").GetBoolean());
+ Assert.Contains(entries, entry =>
+ entry.GetProperty("path").GetString() == "/api/admin/configs" &&
+ !entry.GetProperty("success").GetBoolean());
}
}
diff --git a/backend/MiaoJiZhang.Api.Tests/PushIntegrationTests.cs b/backend/MiaoJiZhang.Api.Tests/PushIntegrationTests.cs
index 9e77a7e..e6824fa 100644
--- a/backend/MiaoJiZhang.Api.Tests/PushIntegrationTests.cs
+++ b/backend/MiaoJiZhang.Api.Tests/PushIntegrationTests.cs
@@ -11,8 +11,6 @@ namespace MiaoJiZhang.Api.Tests;
[Collection(ApiCollection.Name)]
public sealed class PushIntegrationTests(ApiFixture fixture)
{
- private const string AdminKey = "test-only-admin-key-at-least-24-characters";
-
[Fact]
public async Task Preferences_DefaultOff_AndPersistAllCategories()
{
@@ -149,8 +147,7 @@ public sealed class PushIntegrationTests(ApiFixture fixture)
var userId = profile.GetProperty("userId").GetInt64();
await RegisterDeviceAsync(user, Guid.NewGuid().ToString(), "campaign-device-token");
- using var admin = fixture.Factory.CreateClient();
- admin.DefaultRequestHeaders.Add("X-Admin-Key", AdminKey);
+ using var admin = await fixture.AdminAsync();
var request = new
{
title = "系统维护通知",
diff --git a/backend/MiaoJiZhang.Api/Contracts/AdminContracts.cs b/backend/MiaoJiZhang.Api/Contracts/AdminContracts.cs
new file mode 100644
index 0000000..5a3adf6
--- /dev/null
+++ b/backend/MiaoJiZhang.Api/Contracts/AdminContracts.cs
@@ -0,0 +1,7 @@
+namespace MiaoJiZhang.Api.Contracts;
+
+public record AdminLoginRequest(string Username, string Password);
+public record AdminChangePasswordRequest(string CurrentPassword, string NewPassword);
+public record CreateAdminUserRequest(string Username, string Password, string Role);
+public record UpdateAdminUserRequest(string Role, bool IsActive, bool MustChangePassword);
+public record ResetAdminPasswordRequest(string Password);
diff --git a/backend/MiaoJiZhang.Api/Contracts/BusinessContracts.cs b/backend/MiaoJiZhang.Api/Contracts/BusinessContracts.cs
index bb8b354..4c53623 100644
--- a/backend/MiaoJiZhang.Api/Contracts/BusinessContracts.cs
+++ b/backend/MiaoJiZhang.Api/Contracts/BusinessContracts.cs
@@ -21,7 +21,14 @@ public record CreateTransactionRequest(
DateTime? OccurredAt, // null = 现在
string? Source, // manual | voice | ocr
string? SourceText,
- string? ClientRequestId = null);
+ string? ClientRequestId = null,
+ string? TransferDirection = null,
+ string? Counterparty = null,
+ string? Provider = null,
+ string? ProviderTransactionId = null,
+ string? RecognitionOccurrenceId = null,
+ string? EvidenceFingerprint = null,
+ string? RecognitionConfidence = null);
public record UpdateTransactionRequest(
long LedgerId,
@@ -31,14 +38,23 @@ public record UpdateTransactionRequest(
string? Note,
string? PaymentMethod,
DateTime OccurredAt,
- DateTime? BaseUpdatedAt = null);
+ DateTime? BaseUpdatedAt = null,
+ string? TransferDirection = null,
+ string? Counterparty = null);
public record TransactionDto(
long Id, long LedgerId, long CategoryId, string CategoryName, string CategoryIcon,
string Type, decimal Amount, string? Note, string? PaymentMethod,
DateTime OccurredAt, string Source, string? SourceText,
bool IsDeleted = false, string CategoryColor = "mint",
- DateTime? UpdatedAt = null);
+ DateTime? UpdatedAt = null,
+ string? TransferDirection = null,
+ string? Counterparty = null,
+ string? Provider = null,
+ string? ProviderTransactionId = null,
+ string? RecognitionOccurrenceId = null,
+ string? EvidenceFingerprint = null,
+ string? RecognitionConfidence = null);
public record DailyGroupDto(DateOnly Date, decimal Expense, decimal Income, List Items);
@@ -128,15 +144,18 @@ public record PeriodReportDto(
public record OcrParseRequest(string Text, string? Source = null);
public record OcrParseResponse(
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
- decimal Amount, string? PaymentMethod, string Note, string Type);
+ decimal Amount, string? PaymentMethod, string Note, string Type,
+ string? TransferDirection = null, string? Counterparty = null);
public record ImageParseItemResponse(
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
string Type, decimal Amount, string? PaymentMethod, string Note,
- DateTime? OccurredAt);
+ DateTime? OccurredAt,
+ string? TransferDirection = null, string? Counterparty = null);
public record ImageParseResponse(
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
decimal Amount, string? PaymentMethod, string Note, string Type,
- List Items, DateTime? OccurredAt);
+ List Items, DateTime? OccurredAt,
+ string? TransferDirection = null, string? Counterparty = null);
public record RecognitionBatchCandidateRequest(
string CandidateId,
@@ -152,7 +171,13 @@ public record RecognitionBatchCandidateRequest(
string? CategoryHint,
string Confidence,
string? SourceText,
- List? EvidenceIds = null);
+ List? EvidenceIds = null,
+ string? TransferDirection = null,
+ string? Counterparty = null,
+ string? Provider = null,
+ string? ProviderTransactionId = null,
+ string? RecognitionOccurrenceId = null,
+ string? IdentityConfidence = null);
public record RecognitionBatchEvidenceRequest(
string EvidenceId,
@@ -180,7 +205,9 @@ public record RecognitionBatchActionResponse(
string? Note,
DateTime? OccurredAt,
double Confidence,
- string Reason);
+ string Reason,
+ string? TransferDirection = null,
+ string? Counterparty = null);
public record RecognitionBatchResponse(
string BatchId,
@@ -196,7 +223,14 @@ public record RecognitionBatchTransactionItemRequest(
string? PaymentMethod,
DateTime OccurredAt,
string? Source,
- string? SourceText);
+ string? SourceText,
+ string? TransferDirection = null,
+ string? Counterparty = null,
+ string? Provider = null,
+ string? ProviderTransactionId = null,
+ string? RecognitionOccurrenceId = null,
+ string? EvidenceFingerprint = null,
+ string? RecognitionConfidence = null);
public record CreateRecognitionBatchRequest(
string BatchId,
diff --git a/backend/MiaoJiZhang.Api/Controllers/AdminAccountsController.cs b/backend/MiaoJiZhang.Api/Controllers/AdminAccountsController.cs
new file mode 100644
index 0000000..7fdb9d9
--- /dev/null
+++ b/backend/MiaoJiZhang.Api/Controllers/AdminAccountsController.cs
@@ -0,0 +1,145 @@
+using MiaoJiZhang.Api.Contracts;
+using MiaoJiZhang.Api.Services;
+using MiaoJiZhang.Domain.Entities;
+using MiaoJiZhang.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace MiaoJiZhang.Api.Controllers;
+
+[ApiController]
+[AdminAuth(AdminRoles.SuperAdmin)]
+[Route("api/admin/security")]
+public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
+{
+ [HttpGet("accounts")]
+ public async Task Accounts(CancellationToken ct)
+ {
+ var users = await db.AdminUsers.AsNoTracking()
+ .OrderBy(item => item.Username)
+ .Select(item => new
+ {
+ item.Id,
+ item.Username,
+ item.Role,
+ item.IsActive,
+ item.MustChangePassword,
+ item.LastLoginAt,
+ item.CreatedAt,
+ activeSessions = item.Sessions.Count(session =>
+ !session.RevokedAt.HasValue && session.ExpiresAt > DateTime.UtcNow),
+ })
+ .ToListAsync(ct);
+ return Ok(users);
+ }
+
+ [HttpPost("accounts")]
+ public async Task Create(CreateAdminUserRequest request, CancellationToken ct)
+ {
+ var username = request.Username.Trim();
+ if (username.Length is < 3 or > 64 || request.Password.Length is < 12 or > 128 ||
+ !AdminRoles.All.Contains(request.Role))
+ return BadRequest(new ApiError("ADMIN_ACCOUNT_INVALID", "管理员账号、密码或角色无效"));
+ if (await db.AdminUsers.AnyAsync(item => item.Username == username, ct))
+ return Conflict(new ApiError("ADMIN_ACCOUNT_EXISTS", "管理员用户名已存在"));
+ var now = DateTime.UtcNow;
+ var user = new AdminUser
+ {
+ Username = username,
+ PasswordHash = AdminSessionService.HashPassword(request.Password),
+ Role = request.Role,
+ IsActive = true,
+ MustChangePassword = true,
+ CreatedAt = now,
+ UpdatedAt = now,
+ };
+ db.AdminUsers.Add(user);
+ await db.SaveChangesAsync(ct);
+ return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
+ }
+
+ [HttpPut("accounts/{id:long}")]
+ public async Task Update(
+ long id,
+ UpdateAdminUserRequest request,
+ CancellationToken ct)
+ {
+ if (!AdminRoles.All.Contains(request.Role))
+ return BadRequest(new ApiError("ADMIN_ROLE_INVALID", "管理员角色无效"));
+ var user = await db.AdminUsers.FindAsync([id], ct);
+ if (user is null) return NotFound();
+ if (user.Role == AdminRoles.SuperAdmin &&
+ (!request.IsActive || request.Role != AdminRoles.SuperAdmin) &&
+ await ActiveSuperAdminCount(ct) <= 1)
+ {
+ return Conflict(new ApiError("LAST_SUPER_ADMIN", "不能停用或降级最后一个超级管理员"));
+ }
+ user.Role = request.Role;
+ user.IsActive = request.IsActive;
+ user.MustChangePassword = request.MustChangePassword;
+ user.AuthVersion++;
+ user.UpdatedAt = DateTime.UtcNow;
+ await RevokeSessions(id, ct);
+ await db.SaveChangesAsync(ct);
+ return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
+ }
+
+ [HttpPut("accounts/{id:long}/password")]
+ public async Task ResetPassword(
+ long id,
+ ResetAdminPasswordRequest request,
+ CancellationToken ct)
+ {
+ if (request.Password.Length is < 12 or > 128)
+ return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 12 到 128 位"));
+ var user = await db.AdminUsers.FindAsync([id], ct);
+ if (user is null) return NotFound();
+ user.PasswordHash = AdminSessionService.HashPassword(request.Password);
+ user.MustChangePassword = true;
+ user.AuthVersion++;
+ user.UpdatedAt = DateTime.UtcNow;
+ await RevokeSessions(id, ct);
+ await db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpPost("accounts/{id:long}/revoke-sessions")]
+ public async Task Revoke(long id, CancellationToken ct)
+ {
+ if (!await db.AdminUsers.AnyAsync(item => item.Id == id, ct)) return NotFound();
+ await RevokeSessions(id, ct);
+ return NoContent();
+ }
+
+ [HttpGet("audit")]
+ public async Task Audit(
+ [FromQuery] string? username,
+ [FromQuery] int page = 1,
+ [FromQuery] int limit = 50,
+ CancellationToken ct = default)
+ {
+ page = Math.Max(1, page);
+ limit = Math.Clamp(limit, 1, 100);
+ var query = db.AdminAuditLogs.AsNoTracking();
+ if (!string.IsNullOrWhiteSpace(username))
+ {
+ var term = username.Trim();
+ query = query.Where(item => item.Username != null && item.Username.Contains(term));
+ }
+ var total = await query.CountAsync(ct);
+ var list = await query.OrderByDescending(item => item.CreatedAt)
+ .Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
+ return Ok(new { total, page, list });
+ }
+
+ private Task ActiveSuperAdminCount(CancellationToken ct) => db.AdminUsers.CountAsync(
+ item => item.IsActive && item.Role == AdminRoles.SuperAdmin,
+ ct);
+
+ private Task RevokeSessions(long userId, CancellationToken ct)
+ {
+ var now = DateTime.UtcNow;
+ return db.AdminSessions.Where(item => item.AdminUserId == userId && !item.RevokedAt.HasValue)
+ .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct);
+ }
+}
diff --git a/backend/MiaoJiZhang.Api/Controllers/AdminAuthController.cs b/backend/MiaoJiZhang.Api/Controllers/AdminAuthController.cs
new file mode 100644
index 0000000..64fc5c4
--- /dev/null
+++ b/backend/MiaoJiZhang.Api/Controllers/AdminAuthController.cs
@@ -0,0 +1,85 @@
+using MiaoJiZhang.Api.Contracts;
+using MiaoJiZhang.Api.Services;
+using MiaoJiZhang.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.EntityFrameworkCore;
+
+namespace MiaoJiZhang.Api.Controllers;
+
+[ApiController]
+[Route("api/admin/auth")]
+public sealed class AdminAuthController(
+ AppDbContext db,
+ AdminSessionService sessions) : ControllerBase
+{
+ [HttpPost("login")]
+ [EnableRateLimiting("admin-auth")]
+ public async Task Login(AdminLoginRequest request, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrEmpty(request.Password))
+ return BadRequest(new ApiError("ADMIN_LOGIN_INVALID", "请输入用户名和密码"));
+ var result = await sessions.LoginAsync(
+ HttpContext,
+ request.Username,
+ request.Password,
+ ct);
+ return result is null
+ ? Unauthorized(new ApiError("ADMIN_LOGIN_FAILED", "用户名或密码错误,账号也可能已锁定"))
+ : Ok(ToResponse(result.Principal, result.CsrfToken));
+ }
+
+ [HttpGet("me")]
+ [AdminAuth]
+ public async Task Me(CancellationToken ct)
+ {
+ var principal = AdminRequestContext.Principal(HttpContext)!;
+ var csrfToken = await sessions.RotateCsrfAsync(principal.SessionId, ct);
+ return Ok(ToResponse(principal, csrfToken));
+ }
+
+ [HttpPost("logout")]
+ [AdminAuth]
+ public async Task Logout(CancellationToken ct)
+ {
+ var principal = AdminRequestContext.Principal(HttpContext)!;
+ await sessions.LogoutAsync(HttpContext, principal.SessionId, ct);
+ return NoContent();
+ }
+
+ [HttpPut("password")]
+ [AdminAuth]
+ public async Task ChangePassword(
+ AdminChangePasswordRequest request,
+ CancellationToken ct)
+ {
+ if (request.NewPassword.Length < 12 || request.NewPassword.Length > 128)
+ return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 12 到 128 位"));
+ var principal = AdminRequestContext.Principal(HttpContext)!;
+ var user = await db.AdminUsers.FirstAsync(item => item.Id == principal.UserId, ct);
+ if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
+ return BadRequest(new ApiError("ADMIN_PASSWORD_INCORRECT", "当前密码不正确"));
+ if (BCrypt.Net.BCrypt.Verify(request.NewPassword, user.PasswordHash))
+ return BadRequest(new ApiError("ADMIN_PASSWORD_UNCHANGED", "新密码不能与当前密码相同"));
+
+ user.PasswordHash = AdminSessionService.HashPassword(request.NewPassword);
+ user.MustChangePassword = false;
+ user.AuthVersion++;
+ user.UpdatedAt = DateTime.UtcNow;
+ await sessions.RevokeOtherSessionsAsync(user.Id, principal.SessionId, user.AuthVersion, ct);
+ await db.SaveChangesAsync(ct);
+ var csrfToken = await sessions.RotateCsrfAsync(principal.SessionId, ct);
+ var updated = principal with { MustChangePassword = false };
+ HttpContext.Items[AdminRequestContext.PrincipalKey] = updated;
+ return Ok(ToResponse(updated, csrfToken));
+ }
+
+ private static object ToResponse(AdminPrincipal principal, string csrfToken) => new
+ {
+ id = principal.UserId,
+ principal.Username,
+ principal.Role,
+ principal.MustChangePassword,
+ csrfToken,
+ };
+}
diff --git a/backend/MiaoJiZhang.Api/Controllers/BudgetsController.cs b/backend/MiaoJiZhang.Api/Controllers/BudgetsController.cs
index 287c796..2076e3b 100644
--- a/backend/MiaoJiZhang.Api/Controllers/BudgetsController.cs
+++ b/backend/MiaoJiZhang.Api/Controllers/BudgetsController.cs
@@ -45,7 +45,9 @@ public class BudgetsController(
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var spentByCat = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == targetLedgerId &&
- t.Type == TransactionType.Expense && t.OccurredAt >= start && t.OccurredAt < end)
+ (t.Type == TransactionType.Expense ||
+ t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
+ t.OccurredAt >= start && t.OccurredAt < end)
.GroupBy(t => t.CategoryId)
.Select(g => new { g.Key, Amount = g.Sum(t => t.Amount) })
.ToDictionaryAsync(x => x.Key, x => x.Amount);
@@ -152,7 +154,8 @@ public class BudgetsController(
var transactions = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == ledgerId &&
- t.Type == TransactionType.Expense &&
+ (t.Type == TransactionType.Expense ||
+ t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= historyStart && t.OccurredAt < currentEnd)
.Select(t => new { t.CategoryId, t.Amount, t.OccurredAt })
.ToListAsync();
diff --git a/backend/MiaoJiZhang.Api/Controllers/ChatController.cs b/backend/MiaoJiZhang.Api/Controllers/ChatController.cs
index c0bec58..1584ca7 100644
--- a/backend/MiaoJiZhang.Api/Controllers/ChatController.cs
+++ b/backend/MiaoJiZhang.Api/Controllers/ChatController.cs
@@ -358,17 +358,21 @@ public class ChatController(
if (transactions.Count == 1)
{
var transaction = transactions[0];
- var type = transaction.Type == TransactionType.Income
- ? "收入"
- : "支出";
+ var type = transaction.Type switch
+ {
+ TransactionType.Income => "收入",
+ TransactionType.Transfer when transaction.TransferDirection == TransferDirection.In => "转入",
+ TransactionType.Transfer => "转出",
+ _ => "支出",
+ };
return $"已记录{type}:{transaction.Category.Name} ¥{transaction.Amount:F2}{tic}";
}
var income = transactions
- .Where(t => t.Type == TransactionType.Income)
+ .Where(t => t.Type.IsIncome(t.TransferDirection))
.Sum(t => t.Amount);
var expense = transactions
- .Where(t => t.Type == TransactionType.Expense)
+ .Where(t => t.Type.IsExpense(t.TransferDirection))
.Sum(t => t.Amount);
return $"已记录 {transactions.Count} 笔,其中收入 ¥{income:F2}、支出 ¥{expense:F2}{tic}";
}
@@ -530,4 +534,4 @@ public class ChatController(
transaction,
message.CreatedAt);
}
-}
\ No newline at end of file
+}
diff --git a/backend/MiaoJiZhang.Api/Controllers/ParseController.cs b/backend/MiaoJiZhang.Api/Controllers/ParseController.cs
index e4dece2..65bab68 100644
--- a/backend/MiaoJiZhang.Api/Controllers/ParseController.cs
+++ b/backend/MiaoJiZhang.Api/Controllers/ParseController.cs
@@ -83,19 +83,22 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
"expense"));
}
- var bill = drafts[0];
- var category = await db.Categories.FirstAsync(
- c => c.Id == bill.CategoryId && c.Type == bill.Type,
- ct);
+ var bill = drafts[0];
+ var category = await db.Categories.FirstAsync(
+ c => c.Id == bill.CategoryId &&
+ c.Type == bill.Type.CategoryType(bill.TransferDirection),
+ ct);
return Ok(new OcrParseResponse(
true,
category.Id,
category.Name,
category.IconKey,
bill.Amount,
- bill.PaymentMethod,
- bill.Note,
- bill.Type == TransactionType.Income ? "income" : "expense"));
+ bill.PaymentMethod,
+ bill.Note,
+ bill.Type.ToWire(),
+ bill.TransferDirection.ToWire(),
+ bill.Counterparty));
}
/// 上传截屏或小票,提取其中全部独立交易。
@@ -171,18 +174,24 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
foreach (var result in results.Take(20))
{
var normalizedType = result.Type.Trim().ToLowerInvariant();
- if (normalizedType is not ("income" or "expense"))
+ var transferDirection = result.TransferDirection is "in" or "out"
+ ? result.TransferDirection
+ : null;
+ if (normalizedType is not ("income" or "expense" or "transfer") ||
+ normalizedType == "transfer" && transferDirection is null)
{
items.Add(new ImageParseItemResponse(
false, 0, "待确认", "tag", "unknown",
result.Amount, result.PaymentMethod, result.Note,
- result.OccurredAt));
+ result.OccurredAt,
+ transferDirection,
+ result.Counterparty));
continue;
}
- var type = normalizedType == "income"
- ? TransactionType.Income
- : TransactionType.Expense;
- var category = FindCategory(categories, type, result.CategoryName);
+ var categoryType = normalizedType == "income" || transferDirection == "in"
+ ? TransactionType.Income
+ : TransactionType.Expense;
+ var category = FindCategory(categories, categoryType, result.CategoryName);
items.Add(new ImageParseItemResponse(
true,
category.Id,
@@ -192,7 +201,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
result.Amount,
result.PaymentMethod,
result.Note,
- result.OccurredAt));
+ result.OccurredAt,
+ transferDirection,
+ result.Counterparty));
}
var first = items[0];
@@ -206,7 +217,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
first.Note,
first.Type,
items,
- first.OccurredAt));
+ first.OccurredAt,
+ first.TransferDirection,
+ first.Counterparty));
}
[HttpPost("recognition-batch")]
@@ -307,7 +320,12 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
candidate.RecognitionKind,
candidate.CategoryHint,
candidate.Confidence,
- candidate.EvidenceIds ?? [])).ToList();
+ candidate.EvidenceIds ?? [],
+ candidate.TransferDirection,
+ candidate.Counterparty,
+ candidate.ProviderTransactionId,
+ candidate.RecognitionOccurrenceId,
+ candidate.IdentityConfidence)).ToList();
IReadOnlyList? modelActions;
try
@@ -366,11 +384,29 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
action = "keep";
reason = "撤销证据不足,已保留本地结果";
}
- var type = model?.Type is "income" or "expense" ? model.Type : candidate.Type;
+ var type = model?.Type is "income" or "expense" or "transfer"
+ ? model.Type
+ : candidate.Type;
+ var transferDirection = type == "transfer"
+ ? model?.TransferDirection is "in" or "out"
+ ? model.TransferDirection
+ : candidate.TransferDirection is "in" or "out"
+ ? candidate.TransferDirection
+ : null
+ : null;
+ if (type == "transfer" && transferDirection is null)
+ {
+ action = "keep";
+ type = candidate.Type;
+ transferDirection = candidate.TransferDirection;
+ reason = "转账方向不明确,已保留本地结果";
+ }
var amount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
var category = FindCategory(
categories,
- type == "income" ? TransactionType.Income : TransactionType.Expense,
+ type == "income" || transferDirection == "in"
+ ? TransactionType.Income
+ : TransactionType.Expense,
model?.CategoryName ?? candidate.CategoryHint ?? "其他");
result.Add(new RecognitionBatchActionResponse(
action,
@@ -385,7 +421,11 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
string.IsNullOrWhiteSpace(model?.Note) ? candidate.Merchant : model.Note,
model?.OccurredAt ?? candidate.OccurredAt,
confidence,
- reason));
+ reason,
+ transferDirection,
+ string.IsNullOrWhiteSpace(model?.Counterparty)
+ ? candidate.Counterparty
+ : model.Counterparty));
}
var usedEvidence = new HashSet();
@@ -395,13 +435,16 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
!evidenceById.TryGetValue(model.EvidenceId, out var evidence) ||
!string.IsNullOrWhiteSpace(evidence.CandidateId) ||
!usedEvidence.Add(model.EvidenceId) ||
- model.Type is not ("income" or "expense") ||
+ model.Type is not ("income" or "expense" or "transfer") ||
+ model.Type == "transfer" && model.TransferDirection is not ("in" or "out") ||
model.Amount is not > 0)
{
continue;
}
- var type = model.Type == "income" ? TransactionType.Income : TransactionType.Expense;
- var category = FindCategory(categories, type, model.CategoryName ?? "其他");
+ var categoryType = model.Type == "income" || model.TransferDirection == "in"
+ ? TransactionType.Income
+ : TransactionType.Expense;
+ var category = FindCategory(categories, categoryType, model.CategoryName ?? "其他");
result.Add(new RecognitionBatchActionResponse(
"create",
model.ActionId,
@@ -415,7 +458,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
model.Note,
model.OccurredAt ?? evidence.CapturedAt,
model.Confidence,
- model.Reason));
+ model.Reason,
+ model.TransferDirection,
+ model.Counterparty));
}
return result.Take(20).ToList();
}
diff --git a/backend/MiaoJiZhang.Api/Controllers/ReportsController.cs b/backend/MiaoJiZhang.Api/Controllers/ReportsController.cs
index c2ee26d..4c3f666 100644
--- a/backend/MiaoJiZhang.Api/Controllers/ReportsController.cs
+++ b/backend/MiaoJiZhang.Api/Controllers/ReportsController.cs
@@ -129,10 +129,10 @@ public class ReportsController(
.ToListAsync();
var income = list
- .Where(transaction => transaction.Type == TransactionType.Income)
+ .Where(transaction => transaction.Type.IsIncome(transaction.TransferDirection))
.Sum(transaction => transaction.Amount);
var expenses = list
- .Where(transaction => transaction.Type == TransactionType.Expense)
+ .Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
.ToList();
var expense = expenses.Sum(transaction => transaction.Amount);
var aiCount = list.Count(
@@ -241,8 +241,8 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
- if (type is not null && type is not ("income" or "expense"))
- return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
+ if (type is not null && type is not ("income" or "expense" or "transfer"))
+ return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 transfer"));
var query = db.Transactions.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value);
@@ -251,12 +251,14 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
var keyword = q.Trim();
query = query.Where(t =>
(t.Note != null && t.Note.Contains(keyword)) ||
+ (t.Counterparty != null && t.Counterparty.Contains(keyword)) ||
t.Category.Name.Contains(keyword) ||
(t.SourceText != null && t.SourceText.Contains(keyword)));
}
if (categoryId.HasValue) query = query.Where(t => t.CategoryId == categoryId.Value);
if (type == "income") query = query.Where(t => t.Type == TransactionType.Income);
if (type == "expense") query = query.Where(t => t.Type == TransactionType.Expense);
+ if (type == "transfer") query = query.Where(t => t.Type == TransactionType.Transfer);
if (minAmount.HasValue) query = query.Where(t => t.Amount >= minAmount.Value);
if (maxAmount.HasValue) query = query.Where(t => t.Amount <= maxAmount.Value);
if (from.HasValue) query = query.Where(t => t.OccurredAt >= NormalizeTime(from.Value));
@@ -281,4 +283,3 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
private static DateTime NormalizeTime(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
}
-
diff --git a/backend/MiaoJiZhang.Api/Controllers/TransactionsController.cs b/backend/MiaoJiZhang.Api/Controllers/TransactionsController.cs
index da5eaf9..c16bbd6 100644
--- a/backend/MiaoJiZhang.Api/Controllers/TransactionsController.cs
+++ b/backend/MiaoJiZhang.Api/Controllers/TransactionsController.cs
@@ -27,19 +27,31 @@ public class TransactionsController(
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
var type = ParseType(req.Type);
if (!type.HasValue)
- return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
+ return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 transfer"));
+ var transferDirection = ParseTransferDirection(req.TransferDirection);
+ if (type == TransactionType.Transfer && !transferDirection.HasValue)
+ return BadRequest(new ApiError("TRANSFER_DIRECTION_REQUIRED", "转账必须选择转入或转出"));
+ if (type != TransactionType.Transfer && !string.IsNullOrWhiteSpace(req.TransferDirection))
+ return BadRequest(new ApiError("TRANSFER_DIRECTION_INVALID", "非转账账单不能设置转账方向"));
+ var categoryType = CategoryTypeFor(type.Value, transferDirection);
var clientRequestId = string.IsNullOrWhiteSpace(req.ClientRequestId)
? null
: req.ClientRequestId.Trim();
+ var provider = NormalizeOptional(req.Provider, 24);
+ var providerTransactionId = NormalizeOptional(req.ProviderTransactionId, 128);
+ var occurrenceId = NormalizeOptional(req.RecognitionOccurrenceId, 64);
if (clientRequestId?.Length > 64)
return BadRequest(new ApiError("CLIENT_REQUEST_ID_INVALID", "幂等标识最长 64 个字符"));
- if (clientRequestId != null)
+ if (providerTransactionId is not null && provider is null)
+ return BadRequest(new ApiError("PROVIDER_REQUIRED", "服务商交易号必须同时提供服务商"));
+ if (clientRequestId is not null || providerTransactionId is not null || occurrenceId is not null)
{
- var existing = await db.Transactions
- .Include(t => t.Category)
- .FirstOrDefaultAsync(t =>
- t.UserId == Uid && t.ClientRequestId == clientRequestId);
+ var existing = await FindExistingTransactionAsync(
+ clientRequestId,
+ provider,
+ providerTransactionId,
+ occurrenceId);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
}
@@ -48,7 +60,7 @@ public class TransactionsController(
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var cat = await db.Categories.FirstOrDefaultAsync(c =>
- c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
+ c.Id == req.CategoryId && !c.IsDeleted && c.Type == categoryType &&
(c.UserId == null || c.UserId == Uid));
if (cat is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
@@ -64,9 +76,16 @@ public class TransactionsController(
Amount = req.Amount,
Note = req.Note,
PaymentMethod = req.PaymentMethod,
+ TransferDirection = transferDirection,
+ Counterparty = NormalizeOptional(req.Counterparty, 100),
Source = SourceFromWire(req.Source),
SourceText = req.SourceText,
ClientRequestId = clientRequestId,
+ Provider = provider,
+ ProviderTransactionId = providerTransactionId,
+ RecognitionOccurrenceId = occurrenceId,
+ EvidenceFingerprint = NormalizeOptional(req.EvidenceFingerprint, 64),
+ RecognitionConfidence = NormalizeOptional(req.RecognitionConfidence, 24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
@@ -75,7 +94,7 @@ public class TransactionsController(
try
{
await db.SaveChangesAsync();
- if (tx.Type == TransactionType.Expense)
+ if (IsExpense(tx))
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
@@ -84,17 +103,19 @@ public class TransactionsController(
await writeScope.CommitAsync();
return Ok(ToDto(tx, cat));
}
- catch (DbUpdateException) when (clientRequestId is not null)
+ catch (DbUpdateException) when (
+ clientRequestId is not null || providerTransactionId is not null || occurrenceId is not null)
{
await writeScope.RollbackAsync();
// Another channel may have committed the same recognition candidate
// after the initial lookup. Resolve the unique-key race as idempotent success.
db.Entry(tx).State = EntityState.Detached;
- var existing = await db.Transactions
- .AsNoTracking()
- .Include(t => t.Category)
- .FirstOrDefaultAsync(t =>
- t.UserId == Uid && t.ClientRequestId == clientRequestId);
+ var existing = await FindExistingTransactionAsync(
+ clientRequestId,
+ provider,
+ providerTransactionId,
+ occurrenceId,
+ asNoTracking: true);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
throw;
}
@@ -108,13 +129,26 @@ public class TransactionsController(
if (!Guid.TryParse(req.BatchId, out _) || req.Items.Count is < 1 or > 20)
return BadRequest(new ApiError("BATCH_INVALID", "批次标识或账单数量无效"));
if (req.Items.Select(item => item.CandidateId).Distinct().Count() != req.Items.Count ||
- req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count)
+ req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count ||
+ req.Items.Where(item => !string.IsNullOrWhiteSpace(item.RecognitionOccurrenceId))
+ .Select(item => item.RecognitionOccurrenceId!.Trim()).Distinct().Count() !=
+ req.Items.Count(item => !string.IsNullOrWhiteSpace(item.RecognitionOccurrenceId)) ||
+ req.Items.Where(item => !string.IsNullOrWhiteSpace(item.ProviderTransactionId))
+ .Select(item => $"{item.Provider?.Trim()}\n{item.ProviderTransactionId!.Trim()}")
+ .Distinct().Count() !=
+ req.Items.Count(item => !string.IsNullOrWhiteSpace(item.ProviderTransactionId)))
{
return BadRequest(new ApiError("BATCH_DUPLICATED", "批次中存在重复账单标识"));
}
if (req.Items.Any(item => item.Amount <= 0 ||
item.ClientRequestId.Length is < 1 or > 64 ||
- ParseType(item.Type) is null))
+ ParseType(item.Type) is null ||
+ (ParseType(item.Type) == TransactionType.Transfer &&
+ ParseTransferDirection(item.TransferDirection) is null) ||
+ (ParseType(item.Type) != TransactionType.Transfer &&
+ !string.IsNullOrWhiteSpace(item.TransferDirection)) ||
+ (!string.IsNullOrWhiteSpace(item.ProviderTransactionId) &&
+ string.IsNullOrWhiteSpace(item.Provider))))
{
return BadRequest(new ApiError("BATCH_ITEM_INVALID", "批次中存在无效账单"));
}
@@ -130,22 +164,27 @@ public class TransactionsController(
foreach (var item in req.Items)
{
var type = ParseType(item.Type)!.Value;
- if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != type)
+ var categoryType = CategoryTypeFor(type, ParseTransferDirection(item.TransferDirection));
+ if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != categoryType)
return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
}
- var requestIds = req.Items.Select(item => item.ClientRequestId).ToList();
- var existing = await db.Transactions
- .Include(transaction => transaction.Category)
- .Where(transaction => transaction.UserId == Uid &&
- transaction.ClientRequestId != null &&
- requestIds.Contains(transaction.ClientRequestId))
- .ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
+ var existing = new Dictionary();
+ foreach (var item in req.Items)
+ {
+ var found = await FindExistingTransactionAsync(
+ item.ClientRequestId.Trim(),
+ NormalizeOptional(item.Provider, 24),
+ NormalizeOptional(item.ProviderTransactionId, 128),
+ NormalizeOptional(item.RecognitionOccurrenceId, 64),
+ ct: ct);
+ if (found is not null) existing[item.CandidateId] = found;
+ }
await using var transactionScope = await db.Database.BeginTransactionAsync(ct);
var mapped = new List<(string CandidateId, Transaction Transaction)>();
foreach (var item in req.Items)
{
- if (existing.TryGetValue(item.ClientRequestId, out var found))
+ if (existing.TryGetValue(item.CandidateId, out var found))
{
mapped.Add((item.CandidateId, found));
continue;
@@ -161,10 +200,17 @@ public class TransactionsController(
Amount = item.Amount,
Note = item.Note?.Trim(),
PaymentMethod = item.PaymentMethod?.Trim(),
+ TransferDirection = ParseTransferDirection(item.TransferDirection),
+ Counterparty = NormalizeOptional(item.Counterparty, 100),
OccurredAt = NormalizeOccurredAt(item.OccurredAt),
Source = SourceFromWire(item.Source),
SourceText = item.SourceText,
ClientRequestId = item.ClientRequestId,
+ Provider = NormalizeOptional(item.Provider, 24),
+ ProviderTransactionId = NormalizeOptional(item.ProviderTransactionId, 128),
+ RecognitionOccurrenceId = NormalizeOptional(item.RecognitionOccurrenceId, 64),
+ EvidenceFingerprint = NormalizeOptional(item.EvidenceFingerprint, 64),
+ RecognitionConfidence = NormalizeOptional(item.RecognitionConfidence, 24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
@@ -175,8 +221,8 @@ public class TransactionsController(
{
await db.SaveChangesAsync(ct);
var expenseChanges = mapped
- .Where(item => !existing.ContainsKey(item.Transaction.ClientRequestId ?? "") &&
- item.Transaction.Type == TransactionType.Expense)
+ .Where(item => !existing.ContainsKey(item.CandidateId) &&
+ IsExpense(item.Transaction))
.Select(item => new BudgetExpenseChange(
item.Transaction.LedgerId,
item.Transaction.CategoryId,
@@ -198,17 +244,22 @@ public class TransactionsController(
{
entry.State = EntityState.Detached;
}
- var raced = await db.Transactions
- .AsNoTracking()
- .Include(transaction => transaction.Category)
- .Where(transaction => transaction.UserId == Uid &&
- transaction.ClientRequestId != null &&
- requestIds.Contains(transaction.ClientRequestId))
- .ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
- if (raced.Count != requestIds.Count) throw;
- return Ok(req.Items.Select(item => new RecognitionBatchTransactionDto(
- item.CandidateId,
- ToDto(raced[item.ClientRequestId], raced[item.ClientRequestId].Category))).ToList());
+ var raced = new List();
+ foreach (var item in req.Items)
+ {
+ var found = await FindExistingTransactionAsync(
+ item.ClientRequestId.Trim(),
+ NormalizeOptional(item.Provider, 24),
+ NormalizeOptional(item.ProviderTransactionId, 128),
+ NormalizeOptional(item.RecognitionOccurrenceId, 64),
+ asNoTracking: true,
+ ct: ct);
+ if (found is null) throw;
+ raced.Add(new RecognitionBatchTransactionDto(
+ item.CandidateId,
+ ToDto(found, found.Category)));
+ }
+ return Ok(raced);
}
}
@@ -228,12 +279,18 @@ public class TransactionsController(
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
var type = ParseType(req.Type);
if (!type.HasValue)
- return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
+ return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 transfer"));
+ var transferDirection = ParseTransferDirection(req.TransferDirection);
+ if (type == TransactionType.Transfer && !transferDirection.HasValue)
+ return BadRequest(new ApiError("TRANSFER_DIRECTION_REQUIRED", "转账必须选择转入或转出"));
+ if (type != TransactionType.Transfer && !string.IsNullOrWhiteSpace(req.TransferDirection))
+ return BadRequest(new ApiError("TRANSFER_DIRECTION_INVALID", "非转账账单不能设置转账方向"));
+ var categoryType = CategoryTypeFor(type.Value, transferDirection);
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
if (!ledgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var category = await db.Categories.FirstOrDefaultAsync(c =>
- c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
+ c.Id == req.CategoryId && !c.IsDeleted && c.Type == categoryType &&
(c.UserId == null || c.UserId == Uid));
if (category is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
@@ -247,7 +304,7 @@ public class TransactionsController(
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
});
var expenseChanges = new List();
- if (tx.Type == TransactionType.Expense)
+ if (IsExpense(tx))
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, -tx.Amount));
tx.LedgerId = ledgerId.Value;
@@ -257,9 +314,11 @@ public class TransactionsController(
tx.Amount = req.Amount;
tx.Note = req.Note?.Trim();
tx.PaymentMethod = req.PaymentMethod?.Trim();
+ tx.TransferDirection = transferDirection;
+ tx.Counterparty = NormalizeOptional(req.Counterparty, 100);
tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
tx.UpdatedAt = DateTime.UtcNow;
- if (tx.Type == TransactionType.Expense)
+ if (IsExpense(tx))
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount));
await using var writeScope = await db.Database.BeginTransactionAsync();
@@ -327,7 +386,7 @@ public class TransactionsController(
tx.UpdatedAt = DateTime.UtcNow;
await using var writeScope = await db.Database.BeginTransactionAsync();
await db.SaveChangesAsync();
- if (tx.Type == TransactionType.Expense)
+ if (IsExpense(tx))
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
@@ -378,16 +437,16 @@ public class TransactionsController(
var list = await q.OrderByDescending(t => t.OccurredAt).ToListAsync();
- var income = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
- var expense = list.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount);
+ var income = list.Where(IsIncome).Sum(t => t.Amount);
+ var expense = list.Where(IsExpense).Sum(t => t.Amount);
var days = list
.GroupBy(t => DateOnly.FromDateTime(ChinaClock.ToLocal(t.OccurredAt)))
.OrderByDescending(g => g.Key)
.Select(g => new DailyGroupDto(
g.Key,
- g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount),
- g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount),
+ g.Where(IsExpense).Sum(t => t.Amount),
+ g.Where(IsIncome).Sum(t => t.Amount),
g.Select(t => ToDto(t, t.Category)).ToList()))
.ToList();
@@ -413,9 +472,9 @@ public class TransactionsController(
t.OccurredAt >= start && t.OccurredAt < end)
.ToListAsync();
- var expenses = list.Where(t => t.Type == TransactionType.Expense).ToList();
+ var expenses = list.Where(IsExpense).ToList();
var totalExpense = expenses.Sum(t => t.Amount);
- var totalIncome = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
+ var totalIncome = list.Where(IsIncome).Sum(t => t.Amount);
var byCat = expenses
.GroupBy(t => t.Category)
@@ -438,7 +497,9 @@ public class TransactionsController(
var (prevStart, _) = ChinaClock.MonthRangeUtc(prevYear, prevMonth);
var prevExpense = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
- t.Type == TransactionType.Expense && t.OccurredAt >= prevStart && t.OccurredAt < start)
+ (t.Type == TransactionType.Expense ||
+ t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
+ t.OccurredAt >= prevStart && t.OccurredAt < start)
.SumAsync(t => t.Amount);
var trend = prevExpense == 0 ? "这是你的第一个月记账哦"
: totalExpense > prevExpense * 1.05m ? $"比上月多花了 ¥{(totalExpense - prevExpense):F0}"
@@ -513,11 +574,11 @@ public class TransactionsController(
.ToListAsync();
var expenses = list
- .Where(transaction => transaction.Type == TransactionType.Expense)
+ .Where(IsExpense)
.ToList();
var totalExpense = expenses.Sum(transaction => transaction.Amount);
var totalIncome = list
- .Where(transaction => transaction.Type == TransactionType.Income)
+ .Where(IsIncome)
.Sum(transaction => transaction.Amount);
var byCategory = expenses
.GroupBy(transaction => transaction.Category)
@@ -550,8 +611,8 @@ public class TransactionsController(
return new PeriodTrendPointDto(
$"{pointStart.Month}月",
pointStart,
- pointItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
- pointItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
+ pointItems.Where(IsExpense).Sum(item => item.Amount),
+ pointItems.Where(IsIncome).Sum(item => item.Amount));
})
.ToList();
}
@@ -570,8 +631,8 @@ public class TransactionsController(
return new PeriodTrendPointDto(
label,
date,
- dayItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
- dayItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
+ dayItems.Where(IsExpense).Sum(item => item.Amount),
+ dayItems.Where(IsIncome).Sum(item => item.Amount));
})
.ToList();
}
@@ -580,7 +641,9 @@ public class TransactionsController(
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == resolvedLedgerId.Value &&
- transaction.Type == TransactionType.Expense &&
+ (transaction.Type == TransactionType.Expense ||
+ transaction.Type == TransactionType.Transfer &&
+ transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= previousStart &&
transaction.OccurredAt < start)
.SumAsync(transaction => transaction.Amount);
@@ -634,9 +697,79 @@ public class TransactionsController(
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
+ "transfer" => TransactionType.Transfer,
_ => null,
};
+ private static TransferDirection? ParseTransferDirection(string? value) => value switch
+ {
+ "in" => TransferDirection.In,
+ "out" => TransferDirection.Out,
+ _ => null,
+ };
+
+ private static TransactionType CategoryTypeFor(
+ TransactionType type,
+ TransferDirection? direction) => type == TransactionType.Transfer
+ ? direction == TransferDirection.In ? TransactionType.Income : TransactionType.Expense
+ : type;
+
+ internal static bool IsExpense(Transaction transaction) =>
+ transaction.Type == TransactionType.Expense ||
+ transaction.Type == TransactionType.Transfer &&
+ transaction.TransferDirection == TransferDirection.Out;
+
+ internal static bool IsIncome(Transaction transaction) =>
+ transaction.Type == TransactionType.Income ||
+ transaction.Type == TransactionType.Transfer &&
+ transaction.TransferDirection == TransferDirection.In;
+
+ private static string? NormalizeOptional(string? value, int maxLength)
+ {
+ var normalized = value?.Trim();
+ if (string.IsNullOrEmpty(normalized)) return null;
+ return normalized.Length <= maxLength ? normalized : normalized[..maxLength];
+ }
+
+ private async Task FindExistingTransactionAsync(
+ string? clientRequestId,
+ string? provider,
+ string? providerTransactionId,
+ string? occurrenceId,
+ bool asNoTracking = false,
+ CancellationToken ct = default)
+ {
+ IQueryable Query() => db.Transactions
+ .IgnoreQueryFilters()
+ .Include(transaction => transaction.Category);
+
+ if (provider is not null && providerTransactionId is not null)
+ {
+ var providerMatch = await Track(Query().Where(transaction => transaction.UserId == Uid &&
+ transaction.Provider == provider &&
+ transaction.ProviderTransactionId == providerTransactionId))
+ .FirstOrDefaultAsync(ct);
+ if (providerMatch is not null) return providerMatch;
+ }
+ if (occurrenceId is not null)
+ {
+ var occurrenceMatch = await Track(Query().Where(transaction => transaction.UserId == Uid &&
+ transaction.RecognitionOccurrenceId == occurrenceId))
+ .FirstOrDefaultAsync(ct);
+ if (occurrenceMatch is not null) return occurrenceMatch;
+ }
+ if (clientRequestId is not null)
+ {
+ return await Track(Query().Where(transaction => transaction.UserId == Uid &&
+ transaction.ClientRequestId == clientRequestId))
+ .FirstOrDefaultAsync(ct);
+ }
+ return null;
+
+ IQueryable Track(IQueryable query) =>
+ asNoTracking ? query.AsNoTracking() : query;
+ }
+
private static bool HasVersionConflict(Transaction transaction, DateTime? baseUpdatedAt)
{
if (!baseUpdatedAt.HasValue) return false;
@@ -662,7 +795,12 @@ public class TransactionsController(
internal static TransactionDto ToDto(Transaction t, Category c) => new(
t.Id, t.LedgerId, c.Id, c.Name, c.IconKey,
- t.Type == TransactionType.Income ? "income" : "expense",
+ t.Type switch
+ {
+ TransactionType.Income => "income",
+ TransactionType.Transfer => "transfer",
+ _ => "expense",
+ },
t.Amount,
t.Source == TransactionSource.AiChat
&& !string.IsNullOrWhiteSpace(t.SourceText)
@@ -685,5 +823,17 @@ public class TransactionsController(
t.SourceText,
t.IsDeleted,
c.ColorKey,
- t.UpdatedAt);
+ t.UpdatedAt,
+ t.TransferDirection switch
+ {
+ TransferDirection.In => "in",
+ TransferDirection.Out => "out",
+ _ => null,
+ },
+ t.Counterparty,
+ t.Provider,
+ t.ProviderTransactionId,
+ t.RecognitionOccurrenceId,
+ t.EvidenceFingerprint,
+ t.RecognitionConfidence);
}
diff --git a/backend/MiaoJiZhang.Api/Controllers/UsersController.cs b/backend/MiaoJiZhang.Api/Controllers/UsersController.cs
index 498b392..24505a1 100644
--- a/backend/MiaoJiZhang.Api/Controllers/UsersController.cs
+++ b/backend/MiaoJiZhang.Api/Controllers/UsersController.cs
@@ -206,9 +206,12 @@ public class UsersController(
}),
transactions = transactions.Select(t => new
{
- t.Id, t.LedgerId, t.CategoryId,
- type = t.Type.ToString().ToLowerInvariant(),
- t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
+ t.Id, t.LedgerId, t.CategoryId,
+ type = t.Type.ToString().ToLowerInvariant(),
+ transferDirection = t.TransferDirection.ToWire(),
+ t.Counterparty, t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
+ t.Provider, t.ProviderTransactionId, t.RecognitionOccurrenceId,
+ t.EvidenceFingerprint, t.RecognitionConfidence,
source = t.Source.ToString(), t.SourceText,
t.IsDeleted, t.DeletedAt, t.CreatedAt, t.UpdatedAt,
}),
@@ -225,14 +228,26 @@ public class UsersController(
var ledgerNames = ledgers.ToDictionary(l => l.Id, l => l.Name);
var transactionCsv = new StringBuilder(
- "ID,账本,类型,金额,分类,备注,支付方式,发生时间,来源,已删除\r\n");
+ "ID,账本,类型,转账方向,对方,金额,分类,备注,支付方式,发生时间,来源,已删除\r\n");
foreach (var tx in transactions)
{
transactionCsv.AppendJoin(',', new[]
{
Csv(tx.Id),
Csv(ledgerNames.GetValueOrDefault(tx.LedgerId, "")),
- Csv(tx.Type == TransactionType.Income ? "收入" : "支出"),
+ Csv(tx.Type switch
+ {
+ TransactionType.Income => "收入",
+ TransactionType.Transfer => "转账",
+ _ => "支出",
+ }),
+ Csv(tx.TransferDirection switch
+ {
+ TransferDirection.In => "转入",
+ TransferDirection.Out => "转出",
+ _ => "",
+ }),
+ Csv(tx.Counterparty),
Csv(tx.Amount),
Csv(tx.Category.Name),
Csv(tx.Note),
diff --git a/backend/MiaoJiZhang.Api/Program.cs b/backend/MiaoJiZhang.Api/Program.cs
index 94d992c..22e4419 100644
--- a/backend/MiaoJiZhang.Api/Program.cs
+++ b/backend/MiaoJiZhang.Api/Program.cs
@@ -15,7 +15,7 @@ var authPermitLimit = Math.Max(1, builder.Configuration.GetValue("RateLimiting:A
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
- options.AddPolicy("auth", context =>
+ options.AddPolicy("auth", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
@@ -23,7 +23,16 @@ builder.Services.AddRateLimiter(options =>
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
- }));
+ }));
+ options.AddPolicy("admin-auth", context =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+ _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 5,
+ Window = TimeSpan.FromMinutes(1),
+ QueueLimit = 0,
+ }));
options.AddPolicy("ai", context =>
RateLimitPartition.GetConcurrencyLimiter(
context.User.FindFirst("sub")?.Value ??
@@ -52,6 +61,8 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddScoped();
builder.Services.AddHttpClient("LlmClient");
@@ -84,9 +95,6 @@ if (string.IsNullOrWhiteSpace(conn))
var jwtSecret = builder.Configuration["Jwt:Secret"];
if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret.Length < 32)
throw new InvalidOperationException("必须通过 Jwt__Secret 配置至少 32 位的 JWT 密钥");
-var adminKey = builder.Configuration["Admin:Key"];
-if (string.IsNullOrWhiteSpace(adminKey) || adminKey.Length < 24)
- throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
if (builder.Configuration.GetValue("Push:Enabled"))
{
var pushKey = builder.Configuration["Push:TokenEncryptionKey"];
@@ -156,7 +164,8 @@ var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService();
- await db.Database.MigrateAsync();
+ await db.Database.MigrateAsync();
+ await scope.ServiceProvider.GetRequiredService().EnsureAsync();
if (app.Environment.IsDevelopment())
await DbSeeder.SeedAsync(db);
}
@@ -167,8 +176,9 @@ var buildTime = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") + " UTC";
var apiVersion = builder.Configuration["Build:Version"] ?? "dev";
app.UseAuthentication();
-app.UseRateLimiter();
-app.UseAuthorization();
+app.UseRateLimiter();
+app.UseAuthorization();
+app.UseMiddleware();
app.MapControllers();
app.MapGet("/api/ping", () => Results.Ok(new { status = "ok", version = apiVersion, built = buildTime }));
app.MapGet("/api/version", () => Results.Ok(new { app = "记之 API", version = apiVersion, built = buildTime }));
diff --git a/backend/MiaoJiZhang.Api/Services/AdminAuditMiddleware.cs b/backend/MiaoJiZhang.Api/Services/AdminAuditMiddleware.cs
new file mode 100644
index 0000000..76421e4
--- /dev/null
+++ b/backend/MiaoJiZhang.Api/Services/AdminAuditMiddleware.cs
@@ -0,0 +1,71 @@
+using MiaoJiZhang.Domain.Entities;
+using MiaoJiZhang.Infrastructure.Persistence;
+
+namespace MiaoJiZhang.Api.Services;
+
+public sealed class AdminAuditMiddleware(RequestDelegate next)
+{
+ public async Task InvokeAsync(HttpContext context, AppDbContext db)
+ {
+ var isAdmin = context.Request.Path.StartsWithSegments("/api/admin");
+ var isAuth = context.Request.Path.StartsWithSegments("/api/admin/auth");
+ var shouldAudit = isAdmin && (isAuth ||
+ !AdminSessionService.IsSafeMethod(context.Request.Method));
+ if (!shouldAudit)
+ {
+ await next(context);
+ return;
+ }
+
+ Exception? failure = null;
+ try
+ {
+ await next(context);
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ throw;
+ }
+ finally
+ {
+ try
+ {
+ var principal = AdminRequestContext.Principal(context);
+ var attemptedUsername = context.Items.TryGetValue(
+ AdminRequestContext.AuditUsernameKey,
+ out var attemptedValue)
+ ? attemptedValue?.ToString()
+ : null;
+ var status = failure is null
+ ? context.Response.StatusCode
+ : StatusCodes.Status500InternalServerError;
+ db.AdminAuditLogs.Add(new AdminAuditLog
+ {
+ AdminUserId = principal?.UserId,
+ Username = principal?.Username ?? attemptedUsername,
+ Action = ActionName(context),
+ Resource = context.Request.Path.Value ?? "/api/admin",
+ HttpMethod = context.Request.Method,
+ Path = (context.Request.Path + context.Request.QueryString).ToString(),
+ StatusCode = status,
+ Success = failure is null && status < 400,
+ Detail = failure?.GetType().Name,
+ IpAddress = context.Connection.RemoteIpAddress?.ToString(),
+ CreatedAt = DateTime.UtcNow,
+ });
+ await db.SaveChangesAsync(CancellationToken.None);
+ }
+ catch
+ {
+ // Audit persistence must not replace the original API result.
+ }
+ }
+ }
+
+ private static string ActionName(HttpContext context)
+ {
+ var path = context.Request.Path.Value?.Trim('/').Replace('/', '.') ?? "api.admin";
+ return $"{context.Request.Method.ToLowerInvariant()}.{path}";
+ }
+}
diff --git a/backend/MiaoJiZhang.Api/Services/AdminAuthAttribute.cs b/backend/MiaoJiZhang.Api/Services/AdminAuthAttribute.cs
index 5fb99e2..48dd3c4 100644
--- a/backend/MiaoJiZhang.Api/Services/AdminAuthAttribute.cs
+++ b/backend/MiaoJiZhang.Api/Services/AdminAuthAttribute.cs
@@ -1,24 +1,49 @@
+using MiaoJiZhang.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace MiaoJiZhang.Api.Services;
-///
-/// 管理后台鉴权:请求头 X-Admin-Key 与 appsettings.Admin:Key 匹配即可。
-/// 仅内部使用,不依赖 JWT/用户体系。
-///
-[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
-public class AdminAuthAttribute : Attribute, IAuthorizationFilter
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
+public sealed class AdminAuthAttribute(params string[] roles) : Attribute, IAsyncAuthorizationFilter
{
- public void OnAuthorization(AuthorizationFilterContext context)
+ public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
- var config = context.HttpContext.RequestServices.GetRequiredService();
- var key = config["Admin:Key"];
- if (string.IsNullOrWhiteSpace(key) ||
- !context.HttpContext.Request.Headers.TryGetValue("X-Admin-Key", out var provided) ||
- provided != key)
+ var request = context.HttpContext.Request;
+ var service = context.HttpContext.RequestServices.GetRequiredService();
+ var authenticated = await service.AuthenticateAsync(
+ context.HttpContext,
+ validateCsrf: !AdminSessionService.IsSafeMethod(request.Method),
+ context.HttpContext.RequestAborted);
+ if (authenticated is null)
{
- context.Result = new UnauthorizedObjectResult(new { error = "admin_key_required", message = "请在 Header 中提供 X-Admin-Key" });
+ context.Result = new UnauthorizedObjectResult(new
+ {
+ error = "admin_session_required",
+ message = "管理会话已失效,请重新登录",
+ });
+ return;
}
+
+ var principal = authenticated.Value.Principal;
+ if (principal.MustChangePassword &&
+ !request.Path.StartsWithSegments("/api/admin/auth"))
+ {
+ context.Result = new ObjectResult(new
+ {
+ error = "password_change_required",
+ message = "首次登录必须修改密码",
+ }) { StatusCode = StatusCodes.Status403Forbidden };
+ return;
+ }
+ if (principal.Role == AdminRoles.Viewer &&
+ !AdminSessionService.IsSafeMethod(request.Method) &&
+ !request.Path.StartsWithSegments("/api/admin/auth"))
+ {
+ context.Result = new ForbidResult();
+ return;
+ }
+ if (roles.Length > 0 && !roles.Contains(principal.Role))
+ context.Result = new ForbidResult();
}
-}
\ No newline at end of file
+}
diff --git a/backend/MiaoJiZhang.Api/Services/AdminBootstrapService.cs b/backend/MiaoJiZhang.Api/Services/AdminBootstrapService.cs
new file mode 100644
index 0000000..9dacb06
--- /dev/null
+++ b/backend/MiaoJiZhang.Api/Services/AdminBootstrapService.cs
@@ -0,0 +1,38 @@
+using MiaoJiZhang.Domain.Entities;
+using MiaoJiZhang.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace MiaoJiZhang.Api.Services;
+
+public sealed class AdminBootstrapService(
+ AppDbContext db,
+ IConfiguration configuration,
+ ILogger logger)
+{
+ public async Task EnsureAsync(CancellationToken ct = default)
+ {
+ if (await db.AdminUsers.AnyAsync(ct)) return;
+ var username = configuration["Admin:BootstrapUsername"]?.Trim();
+ var password = configuration["Admin:BootstrapPassword"];
+ if (string.IsNullOrWhiteSpace(username) || username.Length is < 3 or > 64 ||
+ string.IsNullOrWhiteSpace(password) || password.Length < 12)
+ {
+ throw new InvalidOperationException(
+ "首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 12 位");
+ }
+
+ var now = DateTime.UtcNow;
+ db.AdminUsers.Add(new AdminUser
+ {
+ Username = username,
+ PasswordHash = AdminSessionService.HashPassword(password),
+ Role = AdminRoles.SuperAdmin,
+ IsActive = true,
+ MustChangePassword = true,
+ CreatedAt = now,
+ UpdatedAt = now,
+ });
+ await db.SaveChangesAsync(ct);
+ logger.LogWarning("Bootstrapped the first super administrator account: {Username}", username);
+ }
+}
diff --git a/backend/MiaoJiZhang.Api/Services/AdminSessionService.cs b/backend/MiaoJiZhang.Api/Services/AdminSessionService.cs
new file mode 100644
index 0000000..37304bc
--- /dev/null
+++ b/backend/MiaoJiZhang.Api/Services/AdminSessionService.cs
@@ -0,0 +1,217 @@
+using System.Security.Cryptography;
+using System.Text;
+using MiaoJiZhang.Domain.Entities;
+using MiaoJiZhang.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace MiaoJiZhang.Api.Services;
+
+public sealed record AdminPrincipal(
+ long UserId,
+ long SessionId,
+ string Username,
+ string Role,
+ bool MustChangePassword);
+
+public sealed record AdminLoginResult(AdminPrincipal Principal, string CsrfToken);
+
+public static class AdminRequestContext
+{
+ public const string PrincipalKey = "miaoji.admin.principal";
+ public const string AuditUsernameKey = "miaoji.admin.audit.username";
+
+ public static AdminPrincipal? Principal(HttpContext context) =>
+ context.Items.TryGetValue(PrincipalKey, out var value)
+ ? value as AdminPrincipal
+ : null;
+}
+
+public sealed class AdminSessionService(
+ AppDbContext db,
+ IConfiguration configuration,
+ IWebHostEnvironment environment)
+{
+ public const string CookieName = "miaoji_admin_session";
+ public const string CsrfHeader = "X-CSRF-Token";
+ private static readonly TimeSpan IdleLifetime = TimeSpan.FromHours(8);
+ private static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(7);
+ private static readonly TimeSpan LockoutLifetime = TimeSpan.FromMinutes(15);
+
+ public async Task LoginAsync(
+ HttpContext context,
+ string username,
+ string password,
+ CancellationToken ct)
+ {
+ var normalizedUsername = username.Trim();
+ context.Items[AdminRequestContext.AuditUsernameKey] = normalizedUsername;
+ var user = await db.AdminUsers.FirstOrDefaultAsync(
+ item => item.Username == normalizedUsername,
+ ct);
+ var now = DateTime.UtcNow;
+ if (user is null || !user.IsActive ||
+ user.LockedUntil.HasValue && user.LockedUntil.Value > now)
+ {
+ BCrypt.Net.BCrypt.Verify(password, DummyPasswordHash());
+ return null;
+ }
+
+ if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
+ {
+ user.FailedLoginCount++;
+ if (user.FailedLoginCount >= 5)
+ {
+ user.FailedLoginCount = 0;
+ user.LockedUntil = now.Add(LockoutLifetime);
+ }
+ user.UpdatedAt = now;
+ await db.SaveChangesAsync(ct);
+ return null;
+ }
+
+ user.FailedLoginCount = 0;
+ user.LockedUntil = null;
+ user.LastLoginAt = now;
+ user.UpdatedAt = now;
+ var rawToken = NewToken();
+ var csrfToken = NewToken();
+ var session = new AdminSession
+ {
+ AdminUser = user,
+ TokenHash = Hash(rawToken),
+ CsrfTokenHash = Hash(csrfToken),
+ AuthVersion = user.AuthVersion,
+ ExpiresAt = now.Add(IdleLifetime),
+ AbsoluteExpiresAt = now.Add(AbsoluteLifetime),
+ LastSeenAt = now,
+ IpAddress = ClientIp(context),
+ UserAgent = Trim(context.Request.Headers.UserAgent.ToString(), 300),
+ CreatedAt = now,
+ };
+ db.AdminSessions.Add(session);
+ await db.SaveChangesAsync(ct);
+ WriteCookie(context, rawToken, session.AbsoluteExpiresAt);
+
+ var principal = ToPrincipal(user, session);
+ context.Items[AdminRequestContext.PrincipalKey] = principal;
+ return new AdminLoginResult(principal, csrfToken);
+ }
+
+ public async Task<(AdminPrincipal Principal, string CsrfToken)?> AuthenticateAsync(
+ HttpContext context,
+ bool validateCsrf,
+ CancellationToken ct)
+ {
+ if (!context.Request.Cookies.TryGetValue(CookieName, out var token) ||
+ string.IsNullOrWhiteSpace(token))
+ return null;
+
+ var tokenHash = Hash(token);
+ var now = DateTime.UtcNow;
+ var session = await db.AdminSessions
+ .Include(item => item.AdminUser)
+ .FirstOrDefaultAsync(item => item.TokenHash == tokenHash, ct);
+ if (session is null || session.RevokedAt.HasValue ||
+ session.ExpiresAt <= now || session.AbsoluteExpiresAt <= now ||
+ !session.AdminUser.IsActive ||
+ session.AuthVersion != session.AdminUser.AuthVersion)
+ {
+ DeleteCookie(context);
+ return null;
+ }
+
+ var csrfToken = context.Request.Headers[CsrfHeader].FirstOrDefault();
+ if (validateCsrf && (string.IsNullOrWhiteSpace(csrfToken) ||
+ !CryptographicOperations.FixedTimeEquals(
+ Encoding.ASCII.GetBytes(Hash(csrfToken)),
+ Encoding.ASCII.GetBytes(session.CsrfTokenHash))))
+ {
+ return null;
+ }
+
+ if (now - session.LastSeenAt >= TimeSpan.FromMinutes(5))
+ {
+ session.LastSeenAt = now;
+ session.ExpiresAt = Min(now.Add(IdleLifetime), session.AbsoluteExpiresAt);
+ await db.SaveChangesAsync(ct);
+ }
+ var principal = ToPrincipal(session.AdminUser, session);
+ context.Items[AdminRequestContext.PrincipalKey] = principal;
+ return (principal, csrfToken ?? string.Empty);
+ }
+
+ public async Task RotateCsrfAsync(long sessionId, CancellationToken ct)
+ {
+ var session = await db.AdminSessions.FindAsync([sessionId], ct) ??
+ throw new InvalidOperationException("管理会话不存在");
+ var token = NewToken();
+ session.CsrfTokenHash = Hash(token);
+ await db.SaveChangesAsync(ct);
+ return token;
+ }
+
+ public async Task LogoutAsync(HttpContext context, long sessionId, CancellationToken ct)
+ {
+ var session = await db.AdminSessions.FindAsync([sessionId], ct);
+ if (session is not null && !session.RevokedAt.HasValue)
+ {
+ session.RevokedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync(ct);
+ }
+ DeleteCookie(context);
+ }
+
+ public async Task RevokeOtherSessionsAsync(
+ long userId,
+ long currentSessionId,
+ int authVersion,
+ CancellationToken ct)
+ {
+ var now = DateTime.UtcNow;
+ await db.AdminSessions
+ .Where(item => item.AdminUserId == userId && item.Id != currentSessionId &&
+ !item.RevokedAt.HasValue)
+ .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct);
+ var current = await db.AdminSessions.FindAsync([currentSessionId], ct);
+ if (current is not null) current.AuthVersion = authVersion;
+ }
+
+ public static string HashPassword(string password) =>
+ BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
+
+ public static bool IsSafeMethod(string method) =>
+ HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
+
+ private void WriteCookie(HttpContext context, string token, DateTime expiresAt) =>
+ context.Response.Cookies.Append(CookieName, token, CookieOptions(context, expiresAt));
+
+ private void DeleteCookie(HttpContext context) =>
+ context.Response.Cookies.Delete(CookieName, CookieOptions(context, DateTime.UtcNow.AddDays(-1)));
+
+ private CookieOptions CookieOptions(HttpContext context, DateTime expiresAt) => new()
+ {
+ HttpOnly = true,
+ Secure = configuration.GetValue("Admin:CookieSecure") ??
+ (!environment.IsDevelopment() || context.Request.IsHttps),
+ SameSite = SameSiteMode.Strict,
+ Path = "/api/admin",
+ IsEssential = true,
+ Expires = expiresAt,
+ };
+
+ private static AdminPrincipal ToPrincipal(AdminUser user, AdminSession session) =>
+ new(user.Id, session.Id, user.Username, user.Role, user.MustChangePassword);
+
+ private static DateTime Min(DateTime left, DateTime right) => left <= right ? left : right;
+ private static string NewToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
+ .TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ private static string Hash(string value) =>
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
+ private static string? ClientIp(HttpContext context) =>
+ Trim(context.Connection.RemoteIpAddress?.ToString(), 64);
+ private static string? Trim(string? value, int length) =>
+ string.IsNullOrEmpty(value) ? null : value.Length <= length ? value : value[..length];
+
+ private static string DummyPasswordHash() =>
+ "$2a$12$1i3L4fD4PrM9xMVzKDnwoO.nGsRoW6u9Q9tT6A4vPi04QoV9S3Mca";
+}
diff --git a/backend/MiaoJiZhang.Api/Services/AgentService.cs b/backend/MiaoJiZhang.Api/Services/AgentService.cs
index 139f260..972d0fa 100644
--- a/backend/MiaoJiZhang.Api/Services/AgentService.cs
+++ b/backend/MiaoJiZhang.Api/Services/AgentService.cs
@@ -34,8 +34,8 @@ public class AgentService(
"properties": {
"type": {
"type": "string",
- "enum": ["expense", "income"],
- "description": "交易方向,收入必须是 income,支出必须是 expense"
+ "enum": ["expense", "income", "transfer"],
+ "description": "账单类型;明确转账时使用 transfer"
},
"amount": {
"type": "number",
@@ -53,6 +53,15 @@ public class AgentService(
"type": "string",
"description": "可选,例如微信支付、支付宝、现金"
},
+ "transferDirection": {
+ "type": "string",
+ "enum": ["in", "out"],
+ "description": "type=transfer 时必填,转入为 in,转出为 out"
+ },
+ "counterparty": {
+ "type": "string",
+ "description": "转账对方,可选"
+ },
"occurredAt": {
"type": "string",
"description": "可选,ISO 8601 时间;未提时间不要填写"
@@ -105,7 +114,7 @@ public class AgentService(
},
"type": {
"type": "string",
- "enum": ["expense", "income"]
+ "enum": ["expense", "income", "transfer"]
},
"categoryName": {
"type": "string"
@@ -306,9 +315,9 @@ public class AgentService(
foreach (var bill in bills)
{
var category = await db.Categories.FirstAsync(
- c => c.Id == bill.CategoryId && c.Type == bill.Type,
+ c => c.Id == bill.CategoryId && c.Type == bill.Type.CategoryType(bill.TransferDirection),
ct);
- if (category.Type != bill.Type)
+ if (category.Type != bill.Type.CategoryType(bill.TransferDirection))
throw new InvalidOperationException("交易类型与分类类型不一致");
pending.Add(new Transaction
@@ -318,6 +327,8 @@ public class AgentService(
CategoryId = category.Id,
Category = category,
Type = bill.Type,
+ TransferDirection = bill.TransferDirection,
+ Counterparty = bill.Counterparty,
Amount = bill.Amount,
Note = NormalizeNote(bill.Note, sourceText, category.Name),
PaymentMethod = bill.PaymentMethod,
@@ -336,7 +347,7 @@ public class AgentService(
{
await db.SaveChangesAsync(ct);
await budgetPush.EvaluateAsync(userId, pending
- .Where(transaction => transaction.Type == TransactionType.Expense)
+ .Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
.Select(transaction => new BudgetExpenseChange(
transaction.LedgerId,
transaction.CategoryId,
@@ -361,9 +372,9 @@ public class AgentService(
items = created.Select(transaction => new
{
id = transaction.Id,
- type = transaction.Type == TransactionType.Income
- ? "income"
- : "expense",
+ type = transaction.Type.ToWire(),
+ transferDirection = transaction.TransferDirection.ToWire(),
+ transaction.Counterparty,
amount = transaction.Amount,
categoryName = transaction.Category.Name,
note = transaction.Note,
@@ -395,9 +406,20 @@ public class AgentService(
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
+ "transfer" => TransactionType.Transfer,
_ => throw new InvalidOperationException(
- "交易 type 必须是 income 或 expense"),
+ "交易 type 必须是 income、expense 或 transfer"),
};
+ TransferDirection? transferDirection = null;
+ if (type == TransactionType.Transfer)
+ {
+ transferDirection = RequiredString(item, "transferDirection") switch
+ {
+ "in" => TransferDirection.In,
+ "out" => TransferDirection.Out,
+ _ => throw new InvalidOperationException("转账方向必须是 in 或 out"),
+ };
+ }
if (!item.TryGetProperty("amount", out var amountNode) ||
!amountNode.TryGetDecimal(out var amount) ||
amount <= 0)
@@ -411,7 +433,7 @@ public class AgentService(
if (note.Length > 30) note = note[..30];
var category = await ResolveCategoryAsync(
userId,
- type,
+ type.CategoryType(transferDirection),
categoryName,
ct);
@@ -447,7 +469,9 @@ public class AgentService(
string.IsNullOrWhiteSpace(note) ? category.Name : note,
amount,
paymentMethod,
- occurredAt));
+ occurredAt,
+ transferDirection,
+ OptionalString(item, "counterparty", 100)));
}
return bills;
}
@@ -470,18 +494,20 @@ public class AgentService(
.ToListAsync(ct);
var income = transactions
- .Where(t => t.Type == TransactionType.Income)
+ .Where(t => t.Type.IsIncome(t.TransferDirection))
.Sum(t => t.Amount);
var expense = transactions
- .Where(t => t.Type == TransactionType.Expense)
+ .Where(t => t.Type.IsExpense(t.TransferDirection))
.Sum(t => t.Amount);
var categories = transactions
- .GroupBy(t => new { t.Type, t.Category.Name })
+ .GroupBy(t => new
+ {
+ EffectiveType = t.Type.IsIncome(t.TransferDirection) ? "income" : "expense",
+ t.Category.Name,
+ })
.Select(group => new
{
- type = group.Key.Type == TransactionType.Income
- ? "income"
- : "expense",
+ type = group.Key.EffectiveType,
categoryName = group.Key.Name,
amount = group.Sum(t => t.Amount),
})
@@ -524,6 +550,7 @@ public class AgentService(
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
+ "transfer" => TransactionType.Transfer,
_ => throw new InvalidOperationException("筛选类型无效"),
};
query = query.Where(t => t.Type == type);
@@ -552,9 +579,9 @@ public class AgentService(
items = transactions.Select(transaction => new
{
id = transaction.Id,
- type = transaction.Type == TransactionType.Income
- ? "income"
- : "expense",
+ type = transaction.Type.ToWire(),
+ transferDirection = transaction.TransferDirection.ToWire(),
+ transaction.Counterparty,
transaction.Amount,
categoryName = transaction.Category.Name,
transaction.Note,
@@ -605,7 +632,9 @@ public class AgentService(
var expenses = await db.Transactions
.Where(t => t.UserId == userId &&
t.LedgerId == ledgerId &&
- t.Type == TransactionType.Expense &&
+ (t.Type == TransactionType.Expense ||
+ t.Type == TransactionType.Transfer &&
+ t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= start &&
t.OccurredAt < end)
.Select(t => new { t.CategoryId, t.Amount })
@@ -687,7 +716,9 @@ public class AgentService(
private static object ToToolItem(ParsedBill bill) => new
{
- type = bill.Type == TransactionType.Income ? "income" : "expense",
+ type = bill.Type.ToWire(),
+ transferDirection = bill.TransferDirection.ToWire(),
+ bill.Counterparty,
bill.Amount,
bill.CategoryName,
bill.Note,
@@ -706,6 +737,15 @@ public class AgentService(
return node.GetString()!;
}
+ private static string? OptionalString(JsonElement root, string name, int maxLength)
+ {
+ if (!root.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.String)
+ return null;
+ var value = node.GetString()?.Trim();
+ if (string.IsNullOrEmpty(value)) return null;
+ return value.Length <= maxLength ? value : value[..maxLength];
+ }
+
private static (DateTime Start, DateTime End, string Label) ResolvePeriod(
string period)
{
diff --git a/backend/MiaoJiZhang.Api/Services/AiServices.cs b/backend/MiaoJiZhang.Api/Services/AiServices.cs
index a68a388..a73c0d1 100644
--- a/backend/MiaoJiZhang.Api/Services/AiServices.cs
+++ b/backend/MiaoJiZhang.Api/Services/AiServices.cs
@@ -12,8 +12,10 @@ public record ParsedBill(
string CategoryName,
string Note,
decimal Amount,
- string? PaymentMethod,
- DateTime? OccurredAt = null);
+ string? PaymentMethod,
+ DateTime? OccurredAt = null,
+ TransferDirection? TransferDirection = null,
+ string? Counterparty = null);
public record IntentResult(string Kind, ParsedBill? Bill); // bill | query | chat
@@ -67,13 +69,19 @@ public class ReplyService(AppDbContext db)
public async Task BillReplyAsync(long userId, ParsedBill bill)
{
var (persona, tic) = await GetPersonaAsync(userId);
- var action = bill.Type == TransactionType.Income ? "收入" : "支出";
+ var action = bill.Type switch
+ {
+ TransactionType.Income => "收入",
+ TransactionType.Transfer when bill.TransferDirection == TransferDirection.In => "转入",
+ TransactionType.Transfer => "转出",
+ _ => "支出",
+ };
var body = persona switch
{
"gentle" => $"{action}记好啦~{bill.CategoryName} ¥{bill.Amount:F2}",
"strict" => $"已记录{action}:{bill.CategoryName} ¥{bill.Amount:F2}。",
"meme" => $"{action}记上了!{bill.CategoryName} ¥{bill.Amount:F2},家人们谁懂啊",
- _ => bill.Type == TransactionType.Income
+ _ => bill.Type.IsIncome(bill.TransferDirection)
? $"收入到账!{bill.CategoryName} ¥{bill.Amount:F2},钱包回血啦"
: $"记好了!{bill.CategoryName} ¥{bill.Amount:F2},这笔支出我帮你盯着",
};
diff --git a/backend/MiaoJiZhang.Api/Services/BudgetPushService.cs b/backend/MiaoJiZhang.Api/Services/BudgetPushService.cs
index 66ce7d9..4a827d6 100644
--- a/backend/MiaoJiZhang.Api/Services/BudgetPushService.cs
+++ b/backend/MiaoJiZhang.Api/Services/BudgetPushService.cs
@@ -56,7 +56,9 @@ public sealed class BudgetPushService(AppDbContext db)
var spent = await db.Transactions
.Where(transaction => transaction.UserId == userId &&
transaction.LedgerId == group.Key.LedgerId &&
- transaction.Type == TransactionType.Expense &&
+ (transaction.Type == TransactionType.Expense ||
+ transaction.Type == TransactionType.Transfer &&
+ transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= start && transaction.OccurredAt < end)
.GroupBy(transaction => transaction.CategoryId)
.Select(items => new { CategoryId = items.Key, Amount = items.Sum(item => item.Amount) })
diff --git a/backend/MiaoJiZhang.Api/Services/BudgetRecommendationService.cs b/backend/MiaoJiZhang.Api/Services/BudgetRecommendationService.cs
index f2697a1..637214e 100644
--- a/backend/MiaoJiZhang.Api/Services/BudgetRecommendationService.cs
+++ b/backend/MiaoJiZhang.Api/Services/BudgetRecommendationService.cs
@@ -75,7 +75,9 @@ public sealed class BudgetRecommendationService(
.Where(transaction =>
transaction.UserId == userId &&
transaction.LedgerId == ledgerId &&
- transaction.Type == TransactionType.Expense &&
+ (transaction.Type == TransactionType.Expense ||
+ transaction.Type == TransactionType.Transfer &&
+ transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= currentStart &&
transaction.OccurredAt < currentEnd)
.SumAsync(transaction => transaction.Amount, ct);
@@ -179,7 +181,9 @@ public sealed class BudgetRecommendationService(
.Where(transaction =>
transaction.UserId == userId &&
transaction.LedgerId == ledgerId &&
- transaction.Type == TransactionType.Expense &&
+ (transaction.Type == TransactionType.Expense ||
+ transaction.Type == TransactionType.Transfer &&
+ transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= historyStart &&
transaction.OccurredAt < currentEnd)
.Select(transaction => new
diff --git a/backend/MiaoJiZhang.Api/Services/LlmClient.cs b/backend/MiaoJiZhang.Api/Services/LlmClient.cs
index 4638356..8e4fc89 100644
--- a/backend/MiaoJiZhang.Api/Services/LlmClient.cs
+++ b/backend/MiaoJiZhang.Api/Services/LlmClient.cs
@@ -50,8 +50,10 @@ public record ImageParseResult(
decimal Amount,
string CategoryName,
string? PaymentMethod,
- string Note,
- DateTime? OccurredAt);
+ string Note,
+ DateTime? OccurredAt,
+ string? TransferDirection = null,
+ string? Counterparty = null);
public record RecognitionBatchModelCandidate(
string CandidateId,
@@ -65,7 +67,12 @@ public record RecognitionBatchModelCandidate(
string RecognitionKind,
string? CategoryHint,
string Confidence,
- IReadOnlyList EvidenceIds);
+ IReadOnlyList EvidenceIds,
+ string? TransferDirection,
+ string? Counterparty,
+ string? ProviderTransactionId,
+ string? RecognitionOccurrenceId,
+ string? IdentityConfidence);
public record RecognitionBatchModelEvidence(
string EvidenceId,
@@ -95,7 +102,9 @@ public record RecognitionBatchModelAction(
string? Note,
DateTime? OccurredAt,
double Confidence,
- string Reason);
+ string Reason,
+ string? TransferDirection,
+ string? Counterparty);
public class NullLlmClient : ILlmClient
{
diff --git a/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs b/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs
index d28e64b..2240e99 100644
--- a/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs
+++ b/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs
@@ -37,10 +37,11 @@ public partial class OpenAiVisionClient : ILlmClient
CancellationToken ct = default)
{
if (!IsEnabled) return null;
- const string prompt =
- "你是记账意图识别器。只返回 JSON:" +
- "{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"," +
- "\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
+ const string prompt =
+ "你是记账意图识别器。只返回 JSON:" +
+ "{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"|\"transfer\"," +
+ "\"transferDirection\":\"in\"|\"out\"|null,\"counterparty\":null," +
+ "\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
"收入信号包括赚了、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款;" +
"支出分类:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他;" +
"收入分类:工资/奖金/理财/兼职/红包/报销/其他。" +
@@ -62,8 +63,8 @@ public partial class OpenAiVisionClient : ILlmClient
var shanghaiNow = ChinaClock.Now;
var systemPrompt = $$"""
你是账单截图识别器。逐条提取图片中所有独立、真实发生的交易,只返回 JSON:
- {"bills":[{"type":"expense","amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
- type 只能是 expense 或 income。
+ {"bills":[{"type":"expense","transferDirection":null,"counterparty":null,"amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
+ type 只能是 expense、income 或 transfer;transfer 必须同时返回 transferDirection=in|out,counterparty 尽量填写转账对方。
支出分类只能是:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他。
收入分类只能是:工资/奖金/理财/兼职/红包/报销/其他。
note 只写简短商户、商品或交易对象,不要抄整行原文。
@@ -181,11 +182,16 @@ public partial class OpenAiVisionClient : ILlmClient
categoryHint = candidate.CategoryHint,
confidence = candidate.Confidence,
evidenceIds = candidate.EvidenceIds,
+ transferDirection = candidate.TransferDirection,
+ counterparty = candidate.Counterparty,
+ providerTransactionId = candidate.ProviderTransactionId,
+ recognitionOccurrenceId = candidate.RecognitionOccurrenceId,
+ identityConfidence = candidate.IdentityConfidence,
}),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
var systemPrompt = $$"""
你是支付结果批次对账器。只返回 JSON:
- {"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
+ {"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"transferDirection":null,"counterparty":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
当前批次候选:{{candidateJson}}
支出分类只能是:{{string.Join('/', input.ExpenseCategories)}}。
收入分类只能是:{{string.Join('/', input.IncomeCategories)}}。
@@ -193,7 +199,7 @@ public partial class OpenAiVisionClient : ILlmClient
不同 flowSessionId 代表不同支付流程。即使收款人、金额和时间相同,也绝不能据此合并或删除。
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。
- update/create 的 type 只能是 expense 或 income,amount 必须大于 0。
+ update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 transferDirection=in|out。
截图未明确显示时间时沿用候选时间,禁止猜测时间。reason 不超过 40 个汉字。
""";
var messages = new List