Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# Local secrets and environment files
.env
.env.*
**/.env
**/.env.*
*.local
# Build outputs and dependency caches
node_modules/
dist/
dist-ssr/
build/
.dart_tool/
.gradle/
bin/
obj/
coverage/
.downloads/
# Flutter/Dart generated files
**/doc/api/
**/ios/Flutter/.last_build_id
.flutter-plugins-dependencies
.pub-cache/
.pub/
app.*.symbols
app.*.map.json
frontend/android/app/debug/
frontend/android/app/profile/
frontend/android/app/release/
# Release, filing, and backup artifacts
release/
backups/
filing/
*.apk
*.sql
*.pem
*.der
*.p12
*.jks
*.keystore
!frontend/android/app/src/internal/res/raw/
!frontend/android/app/src/internal/res/raw/sakura_frp_test_ca.pem
# Local tool metadata
.agents/
.codex/
.git-local/
.idea/
.vscode/
.DS_Store
*.log
*.tmp
*.swp
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1854
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "admin-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"vue": "^3.5.39",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vue-tsc": "^3.3.5"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const collapsed = ref(false)
const selectedKeys = ref<string[]>([String(route.name)])
watch(() => route.name, (n) => { selectedKeys.value = [String(n)] })
const nav = [
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
{ key: 'Settings', icon: ControlOutlined, label: '系统设置' },
{ key: 'Configs', icon: SettingOutlined, label: '品牌配置' },
{ key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' },
{ key: 'Personas', icon: SmileOutlined, label: 'AI 性格' },
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
]
</script>
<template>
<a-layout style="min-height: 100vh">
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="200"
style="border-right: 1px solid #f0f0f0">
<div style="padding: 18px 20px; font-size: 16px; font-weight: 700; white-space: nowrap; overflow: hidden;">
<span style="color:#25211E;margin-right:6px"></span>记之 Admin
</div>
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" :style="{ borderRight: 0 }"
@click="({key}: {key: string}) => router.push({name: key})">
<a-menu-item v-for="n in nav" :key="n.key">
<component :is="n.icon" />
<span>{{ n.label }}</span>
</a-menu-item>
</a-menu>
<div style="position:absolute;bottom:16px;left:20px;font-size:11px;color:#bbb">v20260718-1630</div>
</a-layout-sider>
<a-layout>
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 10px; min-height: 360px;">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
</template>
+52
View File
@@ -0,0 +1,52 @@
import axios from 'axios'
import { message } from 'ant-design-vue'
const ADMIN_KEY = localStorage.getItem('miaoji_admin_key') || ''
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE || '',
headers: { 'X-Admin-Key': ADMIN_KEY },
})
http.interceptors.response.use(
r => r,
err => {
if (err.response?.status === 401) {
message.error('管理密钥无效,请在 localStorage 设置 miaoji_admin_key')
}
return Promise.reject(err)
}
)
export const api = {
dashboard: () => http.get('/api/admin/dashboard').then(r => r.data),
configs: () => http.get('/api/admin/configs').then(r => r.data),
updateConfig: (id: number, value: string) => http.put(`/api/admin/configs/${id}`, { value }).then(r => r.data),
createConfig: (key: string, value: string) => http.post('/api/admin/configs', { key, value }).then(r => r.data),
personas: () => http.get('/api/admin/personas').then(r => r.data),
createPersona: (d: any) => http.post('/api/admin/personas', d).then(r => r.data),
updatePersona: (id: number, d: any) => http.put(`/api/admin/personas/${id}`, d).then(r => r.data),
deletePersona: (id: number) => http.delete(`/api/admin/personas/${id}`),
avatars: () => http.get('/api/admin/avatars').then(r => r.data),
createAvatar: (d: any) => http.post('/api/admin/avatars', d).then(r => r.data),
updateAvatar: (id: number, d: any) => http.put(`/api/admin/avatars/${id}`, d).then(r => r.data),
deleteAvatar: (id: number) => http.delete(`/api/admin/avatars/${id}`),
stickers: () => http.get('/api/admin/stickers').then(r => r.data),
createSticker: (d: any) => http.post('/api/admin/stickers', d).then(r => r.data),
updateSticker: (id: number, d: any) => http.put(`/api/admin/stickers/${id}`, d).then(r => r.data),
deleteSticker: (id: number) => http.delete(`/api/admin/stickers/${id}`),
users: (params: any) => http.get('/api/admin/users', { params }).then(r => r.data),
toggleBan: (id: number) => http.put(`/api/admin/users/${id}/ban`).then(r => r.data),
updateUserPermissions: (id: number, ai: boolean) => http.put(`/api/admin/users/${id}/permissions`, { ai }).then(r => r.data),
updateAiChatQuota: (id: number, limit: number, period: string, resetUsage: boolean) => http.put(`/api/admin/users/${id}/ai-quota`, { limit, period, resetUsage }).then(r => r.data),
cancelAccountClosure: (id: number) => http.put(`/api/admin/users/${id}/cancel-closure`).then(r => r.data),
userStats: (id: number) => http.get(`/api/admin/users/${id}/stats`).then(r => r.data),
sysCategories: () => http.get('/api/admin/categories').then(r => r.data),
createSysCategory: (d: { name: string; iconKey: string; type: string }) => http.post('/api/admin/categories', d).then(r => r.data),
updateSysCategory: (id: number, d: { name: string; iconKey: string; type: string }) => http.put(`/api/admin/categories/${id}`, d).then(r => r.data),
deleteSysCategory: (id: number) => http.delete(`/api/admin/categories/${id}`),
testLlm: () => http.post('/api/admin/llm/test').then(r => r.data),
}
export default http
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.use(Antd)
app.mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
{ path: '/settings', name: 'Settings', component: () => import('../views/Settings.vue') },
{ path: '/configs', name: 'Configs', component: () => import('../views/Configs.vue') },
{ path: '/categories', name: 'SysCategories', component: () => import('../views/SysCategories.vue') },
{ path: '/personas', name: 'Personas', component: () => import('../views/Personas.vue') },
{ path: '/avatars', name: 'Avatars', component: () => import('../views/Avatars.vue') },
{ path: '/stickers', name: 'Stickers', component: () => import('../views/Stickers.vue') },
{ path: '/users', name: 'Users', component: () => import('../views/Users.vue') },
],
})
export default router
+14
View File
@@ -0,0 +1,14 @@
const shanghaiDateFormatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
export function formatShanghaiDate(value?: string | null): string {
if (!value) return ''
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : value + 'Z'
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) return ''
return shanghaiDateFormatter.format(date).replaceAll('/', '-')
}
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { message } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
interface Avatar { id: number; key: string; defaultName: string; speechTic: string; imageUrl: string | null; isEnabled: boolean }
const list = ref<Avatar[]>([])
const loading = ref(true)
const modalVisible = ref(false)
const editing = ref<Avatar | null>(null)
const form = ref({ key: '', name: '', speechTic: '', imageUrl: '', isEnabled: true })
const cols = [
{ title: 'Key', dataIndex: 'key', key: 'key', width: 90 },
{ title: '默认名', dataIndex: 'defaultName', key: 'name', width: 100 },
{ title: '口癖', dataIndex: 'speechTic', key: 'tic', width: 80 },
{ title: '状态', dataIndex: 'isEnabled', key: 'on', width: 70 },
{ title: '', key: 'act', width: 150 },
]
onMounted(refresh)
async function refresh() {
loading.value = true
try { list.value = await api.avatars() } finally { loading.value = false }
}
function openNew() {
editing.value = null
form.value = { key: '', name: '', speechTic: '', imageUrl: '', isEnabled: true }
modalVisible.value = true
}
function openEdit(a: Avatar) {
editing.value = a
form.value = { key: a.key, name: a.defaultName, speechTic: a.speechTic, imageUrl: a.imageUrl || '', isEnabled: a.isEnabled }
modalVisible.value = true
}
async function save() {
const data = { ...form.value, imageUrl: form.value.imageUrl || null }
if (editing.value) await api.updateAvatar(editing.value.id, data)
else await api.createAvatar(data)
message.success(editing.value ? '已更新' : '已创建')
modalVisible.value = false
refresh()
}
async function del(id: number) {
await api.deleteAvatar(id)
message.success('已删除')
refresh()
}
</script>
<template>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>AI 形象管理</h2>
<a-button type="primary" @click="openNew"><PlusOutlined /> 新建形象</a-button>
</div>
<a-table :columns="cols" :dataSource="list" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'on'">
<a-tag :color="record.isEnabled ? 'green' : 'default'">{{ record.isEnabled ? '启用' : '禁用' }}</a-tag>
</template>
<template v-if="column.key === 'act'">
<a-button size="small" style="margin-right:6px" @click="openEdit(record)"><EditOutlined /></a-button>
<a-popconfirm title="确定删除?" @confirm="del(record.id)">
<a-button size="small" danger><DeleteOutlined /></a-button>
</a-popconfirm>
</template>
</template>
</a-table>
<a-modal v-model:open="modalVisible" :title="editing ? '编辑形象' : '新建形象'" @ok="save" :width="500">
<a-form layout="vertical" style="margin-top:8px">
<a-row :gutter="12">
<a-col :span="12"><a-form-item label="Key"><a-input v-model:value="form.key" placeholder="cat" /></a-form-item></a-col>
<a-col :span="12"><a-form-item label="默认名"><a-input v-model:value="form.name" placeholder="小账喵" /></a-form-item></a-col>
</a-row>
<a-form-item label="口癖后缀">
<a-input v-model:value="form.speechTic" placeholder="喵 / 汪 / 留空=无口癖" />
<div style="color:#999;font-size:11px;margin-top:4px">口癖跟随形象决策 20</div>
</a-form-item>
<a-form-item label="头像图片 URL"><a-input v-model:value="form.imageUrl" placeholder="可选,CDN 地址" /></a-form-item>
<a-form-item label="是否启用"><a-switch v-model:checked="form.isEnabled" /></a-form-item>
</a-form>
</a-modal>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { api } from '../api'
import { message } from 'ant-design-vue'
import { formatShanghaiDate } from '../utils/time'
interface Config { id: number; key: string; value: string; version: number; updatedAt: string }
const list = ref<Config[]>([])
const loading = ref(true)
// Grouped by prefix
const configMeta: Record<string, { label: string; desc: string; type: 'text' | 'number' | 'url' | 'switch' | 'select'; options?: string[] }> = {
'brand.app_name': { label: 'App 名称', desc: 'App 内展示名称', type: 'text' },
'brand.slogan': { label: 'App 标语', desc: '启动页/关于页口号', type: 'text' },
'brand.logo_url': { label: 'Logo URL', desc: '品牌 Logo 远程地址', type: 'url' },
'limit.daily_ai_messages': { label: '全局日限额', desc: '全站每日 AI 消息上限', type: 'number' },
'limit.daily_ai_messages_per_user': { label: '每人日限额', desc: '单用户每日 AI 消息上限', type: 'number' },
'limit.max_monthly_budget': { label: '最大月预算', desc: '用户可设置的最高月预算金额', type: 'number' },
'feature.ocr_enabled': { label: 'OCR 拍照识别', desc: '是否开放 OCR 小票识别功能', type: 'switch' },
'feature.voice_enabled': { label: '语音输入', desc: '是否开放语音记账功能', type: 'switch' },
'feature.ai_auto_book': { label: 'AI 自动入账', desc: 'AI 识别记账意图后是否直接写库', type: 'switch' },
'feature.sticker_enabled': { label: '表情包功能', desc: '是否开放表情包面板和 AI 表情回复', type: 'switch' },
'system.default_ledger_name': { label: '默认账本名', desc: '新用户注册时自动创建', type: 'text' },
'system.max_ledgers_per_user': { label: '每人最多账本', desc: '单用户可创建账本上限', type: 'number' },
}
const groups = [
{ key: 'brand', label: '品牌', prefix: 'brand.' },
{ key: 'limit', label: '限额', prefix: 'limit.' },
{ key: 'feature', label: '功能开关', prefix: 'feature.' },
{ key: 'system', label: '系统', prefix: 'system.' },
]
const grouped = computed(() => {
const map: Record<string, Config[]> = {}
for (const cfg of list.value) {
const g = groups.find(g => cfg.key.startsWith(g.prefix))
const k = g?.key || 'other'
if (!map[k]) map[k] = []
map[k].push(cfg)
}
return map
})
onMounted(refresh)
async function refresh() { loading.value = true; try { list.value = await api.configs() } finally { loading.value = false } }
const editVisible = ref(false)
const editItem = ref<Config | null>(null)
const editValue = ref('')
const editingMeta = computed(() => editItem.value ? configMeta[editItem.value.key] : null)
function openEdit(cfg: Config) { editItem.value = cfg; editValue.value = cfg.value; editVisible.value = true }
async function saveEdit() {
if (!editItem.value) return
await api.updateConfig(editItem.value.id, editValue.value)
message.success(`已更新 ${editItem.value.key}`)
editVisible.value = false
refresh()
}
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<h2 style="margin:0">品牌配置</h2>
<a-button @click="refresh">刷新</a-button>
</div>
<a-tabs>
<a-tab-pane v-for="g in groups" :key="g.key" :tab="g.label">
<a-row :gutter="[16,12]">
<a-col v-for="cfg in grouped[g.key]" :key="cfg.id" :span="8">
<a-card size="small" hoverable @click="openEdit(cfg)">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px">{{ configMeta[cfg.key]?.label || cfg.key }}</div>
<div style="color:#999;font-size:11px;margin-bottom:6px">{{ configMeta[cfg.key]?.desc || '' }}</div>
</div>
<a-tag color="blue" style="margin-left:8px">v{{ cfg.version }}</a-tag>
</div>
<div v-if="configMeta[cfg.key]?.type === 'switch'"
style="margin-top:6px;font-size:18px">
<span v-if="cfg.value === 'true'" style="color:#00B386"> 已开启</span>
<span v-else style="color:#ccc"> 已关闭</span>
</div>
<div v-else style="margin-top:6px;font-size:16px;font-weight:700;word-break:break-all">
{{ cfg.key.includes('key') ? '••••••••' : cfg.value || '(空)' }}
</div>
<div style="color:#999;font-size:10px;margin-top:4px">{{ formatShanghaiDate(cfg.updatedAt) }}</div>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
<a-modal v-model:open="editVisible" :title="`编辑配置: ${editItem?.key}`" @ok="saveEdit" :width="440">
<div style="margin-bottom:10px;color:#999;font-size:12px">{{ editingMeta?.desc }}</div>
<template v-if="editingMeta?.type === 'switch'">
<a-switch
:checked="editValue === 'true'"
@change="(v: boolean) => editValue = String(v)"
checked-children="开启" un-checked-children="关闭" />
</template>
<template v-else-if="editingMeta?.type === 'number'">
<a-input-number v-model:value="(editValue as any)" style="width:100%" />
</template>
<template v-else-if="editingMeta?.type === 'select' && editingMeta.options">
<a-select v-model:value="editValue" style="width:100%" :options="editingMeta.options.map(o=>({value:o,label:o}))" />
</template>
<template v-else>
<a-input v-model:value="editValue" />
</template>
</a-modal>
</div>
</template>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { RobotOutlined, CheckCircleOutlined, RiseOutlined,
TeamOutlined, UserOutlined, MessageOutlined, DollarOutlined } from '@ant-design/icons-vue'
interface DashData {
users: { total: number; activeToday: number }
transactions: { total: number; aiBooked: number }
aiAccuracy: number
undoRate: number
aiMessages: number
}
const dash = ref<DashData | null>(null)
const loading = ref(true)
onMounted(async () => {
try { dash.value = await api.dashboard() } finally { loading.value = false }
})
</script>
<template>
<div v-if="loading" style="text-align:center;padding:60px"><a-spin size="large" /></div>
<template v-else-if="dash">
<h2 style="margin-bottom:18px">📊 仪表盘</h2>
<a-row :gutter="14" style="margin-bottom:14px">
<a-col :span="6">
<a-card size="small"><a-statistic title="总用户" :value="dash.users.total"><template #prefix><TeamOutlined /></template></a-statistic></a-card>
</a-col>
<a-col :span="6">
<a-card size="small"><a-statistic title="今日活跃" :value="dash.users.activeToday"><template #prefix><UserOutlined /></template></a-statistic></a-card>
</a-col>
<a-col :span="6">
<a-card size="small"><a-statistic title="总账单" :value="dash.transactions.total" /></a-card>
</a-col>
<a-col :span="6">
<a-card size="small"><a-statistic title="AI 记账" :value="dash.transactions.aiBooked"><template #prefix><RobotOutlined /></template></a-statistic></a-card>
</a-col>
</a-row>
<a-row :gutter="14" style="margin-bottom:14px">
<a-col :span="6">
<a-card size="small">
<a-statistic title="AI 准确率" :value="dash.aiAccuracy" suffix="%"
:value-style="{ color: dash.aiAccuracy >= 70 ? '#00B386' : '#F0642D' }">
<template #prefix><CheckCircleOutlined /></template></a-statistic>
<a-progress :percent="dash.aiAccuracy" :showInfo="false" size="small"
:strokeColor="dash.aiAccuracy >= 70 ? '#00B386' : '#F0642D'" style="margin-top:6px" />
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic title="撤销率" :value="dash.undoRate" suffix="%"
:value-style="{ color: dash.undoRate <= 20 ? '#00B386' : '#F5A623' }">
<template #prefix><RiseOutlined /></template></a-statistic>
<a-progress :percent="dash.undoRate" :showInfo="false" size="small"
:strokeColor="dash.undoRate <= 20 ? '#00B386' : '#F5A623'" style="margin-top:6px" />
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small"><a-statistic title="AI 消息总数" :value="dash.aiMessages"><template #prefix><MessageOutlined /></template></a-statistic></a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic title="估算 Token 成本" :value="(dash.aiMessages * 500 * 0.01 / 1000).toFixed(2)" prefix="$">
<template #prefix><DollarOutlined /></template>
</a-statistic>
<div style="font-size:10px;color:#999;margin-top:4px">基于 500 token/条 × $0.01/1K 估算</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="14">
<a-col :span="12">
<a-alert type="info" show-icon
message="AI 准确率 = (AI记账总数 撤销数) / AI记账总数撤销率高 需要调整 AI 性格 Prompt 模板" />
</a-col>
<a-col :span="12">
<a-alert type="warning" show-icon
message="注意撤销率AI 消息数估算成本是运营核心指标建议每周跟踪及时优化 Prompt 和限流阈值" />
</a-col>
</a-row>
</template>
</template>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { message } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
interface Persona { id: number; key: string; name: string; description: string; sampleLine: string; promptTemplate: string; isEnabled: boolean; version: number }
const list = ref<Persona[]>([])
const loading = ref(true)
const modalVisible = ref(false)
const editing = ref<Persona | null>(null)
const form = ref({
key: '', name: '', description: '', sampleLine: '', promptTemplate: '', isEnabled: true,
})
const cols = [
{ title: 'Key', dataIndex: 'key', key: 'key', width: 110 },
{ title: '名称', dataIndex: 'name', key: 'name', width: 100 },
{ title: '描述', dataIndex: 'description', key: 'desc', ellipsis: true },
{ title: '版本', dataIndex: 'version', key: 'ver', width: 60 },
{ title: '状态', dataIndex: 'isEnabled', key: 'on', width: 70 },
{ title: '', key: 'act', width: 150 },
]
onMounted(refresh)
async function refresh() {
loading.value = true
try { list.value = await api.personas() } finally { loading.value = false }
}
function openNew() {
editing.value = null
form.value = { key: '', name: '', description: '', sampleLine: '', promptTemplate: '', isEnabled: true }
modalVisible.value = true
}
function openEdit(p: Persona) {
editing.value = p
form.value = { key: p.key, name: p.name, description: p.description, sampleLine: p.sampleLine, promptTemplate: p.promptTemplate, isEnabled: p.isEnabled }
modalVisible.value = true
}
async function save() {
const data = { ...form.value, isEnabled: form.value.isEnabled }
if (editing.value) await api.updatePersona(editing.value.id, data)
else await api.createPersona(data)
message.success(editing.value ? '已更新(版本号+1' : '已创建')
modalVisible.value = false
refresh()
}
async function del(id: number) {
await api.deletePersona(id)
message.success('已删除')
refresh()
}
</script>
<template>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>AI 性格管理</h2>
<a-button type="primary" @click="openNew"><PlusOutlined /> 新建性格</a-button>
</div>
<a-table :columns="cols" :dataSource="list" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'on'">
<a-tag :color="record.isEnabled ? 'green' : 'default'">{{ record.isEnabled ? '启用' : '禁用' }}</a-tag>
</template>
<template v-if="column.key === 'act'">
<a-button size="small" style="margin-right:6px" @click="openEdit(record)"><EditOutlined /></a-button>
<a-popconfirm title="确定删除?" @confirm="del(record.id)">
<a-button size="small" danger><DeleteOutlined /></a-button>
</a-popconfirm>
</template>
</template>
</a-table>
<a-modal v-model:open="modalVisible" :title="editing ? '编辑性格' : '新建性格'" @ok="save" :width="560">
<a-form layout="vertical" style="margin-top:8px">
<a-row :gutter="12">
<a-col :span="12"><a-form-item label="Key"><a-input v-model:value="form.key" placeholder="sassy_cat" /></a-form-item></a-col>
<a-col :span="12"><a-form-item label="名称"><a-input v-model:value="form.name" placeholder="毒舌猫娘" /></a-form-item></a-col>
</a-row>
<a-form-item label="描述"><a-input v-model:value="form.description" placeholder="一句话描述" /></a-form-item>
<a-form-item label="示例台词"><a-input v-model:value="form.sampleLine" placeholder="这句会展示给用户选性格时看" /></a-form-item>
<a-form-item label="Prompt 模板">
<a-textarea v-model:value="form.promptTemplate" :rows="5"
placeholder="系统提示词模板,支持 {tic} 占位符,此字段可调不发版" />
<div style="color:#999;font-size:11px;margin-top:4px">
调这个不需要发版前端实时生效可用占位符<code>{'{tic}'}</code> = 口癖//
</div>
</a-form-item>
<a-form-item label="是否启用"><a-switch v-model:checked="form.isEnabled" /></a-form-item>
</a-form>
</a-modal>
</template>
+234
View File
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { message } from 'ant-design-vue'
interface Config { id: number; key: string; value: string; version: number }
const configs = ref<Record<string, Config>>({})
const loading = ref(true)
const testing = ref(false)
const testOk = ref<boolean | null>(null)
const testMsg = ref('')
const llmKeys = ['llm.protocol','llm.base_url','llm.model','llm.max_tokens','llm.temperature']
const limitKeys = ['limit.daily_ai_messages','limit.daily_ai_messages_per_user','limit.max_monthly_budget']
const featureKeys = ['feature.ocr_enabled','feature.voice_enabled','feature.ai_auto_book','feature.sticker_enabled']
const permissionKeys = ['permission.default.ai_enabled']
const quotaDefaultKeys = ['quota.default_ai_chat_limit','quota.default_ai_chat_period']
const systemKeys = ['system.default_ledger_name','system.max_ledgers_per_user','brand.app_name','brand.slogan','brand.logo_url']
const protocols = [
{ value: 'chat_completions', label: 'Chat Completions', desc: 'OpenAI Chat Completions API。POST /v1/chat/completions', endpoint: '/chat/completions' },
{ value: 'responses', label: 'Responses', desc: 'OpenAI Responses API(新版)。POST /v1/responses', endpoint: '/responses' },
{ value: 'messages', label: 'Messages', desc: 'Anthropic Messages API。POST /v1/messages,用于 Claude 系列', endpoint: '/messages' },
]
const meta: Record<string, { label: string; desc: string; type: string }> = {
'llm.protocol': { label: 'API 协议', desc: '', type: 'protocol' },
'llm.base_url': { label: 'API 地址', desc: '', type: 'url' }, 'llm.model': { label: '模型名称', desc: '', type: 'text' },
'llm.max_tokens': { label: '最大输出 Token', desc: '单次请求上限', type: 'number' },
'llm.temperature': { label: '温度 (Temperature)', desc: '0=确定 1=创意', type: 'number' },
'limit.daily_ai_messages': { label: '全站每日 AI 消息上限', desc: '', type: 'number' },
'limit.daily_ai_messages_per_user': { label: '每人每日 AI 消息上限', desc: '', type: 'number' },
'limit.max_monthly_budget': { label: '最大月预算金额', desc: '', type: 'number' },
'feature.ocr_enabled': { label: 'OCR 小票识别', desc: '拍照记账功能', type: 'switch' },
'feature.voice_enabled': { label: '语音记账', desc: '语音输入转文字', type: 'switch' },
'feature.ai_auto_book': { label: 'AI 自动入账', desc: 'AI 识别记账意图直接入账', type: 'switch' },
'feature.sticker_enabled': { label: '表情包功能', desc: '聊天表情包面板 + AI 表情回复', type: 'switch' },
'permission.default.ai_enabled': { label: '新用户默认启用 AI', desc: '仅影响保存后注册的新用户,现有用户权限不变', type: 'switch' },
'quota.default_ai_chat_limit': { label: '新用户默认 AI 对话次数', desc: '0 表示不限次数', type: 'number' },
'quota.default_ai_chat_period': { label: '新用户默认额度周期', desc: '按上海时区自然周期重置', type: 'period' },
'system.default_ledger_name': { label: '新用户默认账本名', desc: '', type: 'text' },
'system.max_ledgers_per_user': { label: '每人最多账本数', desc: '', type: 'number' },
'brand.app_name': { label: 'App 名称', desc: '', type: 'text' },
'brand.slogan': { label: 'App 标语', desc: '', type: 'text' },
'brand.logo_url': { label: 'Logo URL', desc: '', type: 'url' },
}
onMounted(async () => {
try {
const list = await api.configs()
const map: Record<string, Config> = {}
for (const c of list) map[c.key] = c
configs.value = map
if (!map['llm.protocol']) setVal('llm.protocol', 'chat_completions')
if (!map['quota.default_ai_chat_limit']) setVal('quota.default_ai_chat_limit', 50)
if (!map['quota.default_ai_chat_period']) setVal('quota.default_ai_chat_period', 'day')
selProtocol.value = getVal('llm.protocol') || 'chat_completions'
} finally { loading.value = false }
})
function getVal(key: string): any {
const c = configs.value[key]
if (!c) return ''
const m = meta[key]
if (m?.type === 'number') return Number(c.value) || 0
if (m?.type === 'switch') return c.value === 'true'
return c.value
}
function setVal(key: string, val: any) {
const str = typeof val === 'boolean' ? String(val) : String(val)
if (configs.value[key]) configs.value[key].value = str
else configs.value[key] = { id: 0, key, value: str, version: 0 }
}
const selProtocol = ref('chat_completions')
async function saveSection(keys: string[]) {
setVal('llm.protocol', selProtocol.value)
const tasks = keys.filter(k => configs.value[k]).map(async k => {
const cfg = configs.value[k]
try {
if (cfg.id > 0) {
const r = await api.updateConfig(cfg.id, cfg.value)
if (cfg) cfg.id = r.id
} else {
const r = await api.createConfig(cfg.key, cfg.value)
if (cfg) cfg.id = r.id
}
} catch (e: any) {
message.error(k + ' 保存失败: ' + (e?.response?.data?.detail || e?.response?.data?.error || e.message))
}
})
if (tasks.length === 0) { message.warning('没有可保存的配置'); return }
await Promise.all(tasks)
message.success('已保存 ' + tasks.length + ' 项配置')
}
async function testLlm() {
testing.value = true; testOk.value = null; testMsg.value = ''
try {
const r = await api.testLlm()
testOk.value = r.ok
testMsg.value = r.detail || r.error || ''
if (r.ok) message.success('LLM 连接正常')
else message.error(r.error || '连接失败')
} catch (e: any) {
testOk.value = false
const data = e?.response?.data
testMsg.value = data?.detail || data?.error || data?.message || e.message || '未知错误'
message.error('连接失败')
} finally { testing.value = false }
}
</script>
<template>
<div v-if="loading" style="text-align:center;padding:60px"><a-spin size="large" /></div>
<template v-else>
<h2 style="margin-bottom:18px">系统设置</h2>
<a-card title="LLM 大模型配置" size="small" style="margin-bottom:14px">
<template #extra>
<a-space>
<a-button size="small" @click="testLlm" :loading="testing">测试连接</a-button>
<a-button size="small" type="primary" @click="saveSection(llmKeys)">保存</a-button>
</a-space>
</template>
<template v-if="testOk !== null">
<a-alert :type="testOk ? 'success' : 'error'" :message="testOk ? '连接成功' : '连接失败'" :description="testMsg" show-icon style="margin-bottom:14px" closable />
</template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="API 协议" :span="2">
<a-radio-group v-model:value="selProtocol" style="width:100%">
<a-row :gutter="[8,8]">
<a-col v-for="p in protocols" :key="p.value" :span="8">
<a-radio :value="p.value" style="display:block">
<span style="font-weight:600">{{ p.label }}</span>
<div style="font-size:11px;color:#999;margin-top:2px;white-space:normal">{{ p.desc }}</div>
<div style="font-size:10px;color:#bbb;margin-top:2px;font-family:monospace">{{ p.endpoint }}</div>
</a-radio>
</a-col>
</a-row>
</a-radio-group>
</a-descriptions-item>
<a-descriptions-item label="API 地址" :span="2">
<a-input :value="getVal('llm.base_url')" @change="(e:any)=>setVal('llm.base_url', e.target.value)" placeholder="https://api.openai.com/v1" />
<div style="color:#999;font-size:11px;margin-top:2px">会自动拼上协议路径</div>
</a-descriptions-item>
<a-descriptions-item label="API Key" :span="2">
<a-alert type="info" message="API Key 仅通过服务器环境变量 LLM_API_KEY 配置,后台不会读取或显示完整密钥。" show-icon />
</a-descriptions-item>
<a-descriptions-item label="模型名称" :span="2">
<a-input :value="getVal('llm.model')" @change="(e:any)=>setVal('llm.model', e.target.value)" placeholder="gpt-4o-mini" />
</a-descriptions-item>
<a-descriptions-item label="最大输出 Token" :span="1">
<a-input-number :value="getVal('llm.max_tokens')" @change="(val:any)=>setVal('llm.max_tokens', val)" style="width:100%" :min="1" />
</a-descriptions-item>
<a-descriptions-item label="温度" :span="1">
<a-input-number :value="getVal('llm.temperature')" @change="(val:any)=>setVal('llm.temperature', val)" style="width:100%" :min="0" :max="2" :step="0.1" />
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="限额配置" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(limitKeys)">保存</a-button></template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item v-for="k in limitKeys" :key="k" :label="meta[k]?.label" :span="1">
<a-input-number :value="getVal(k)" @change="(val:any)=>setVal(k, val)" style="width:100%" :min="0" />
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="功能开关" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(featureKeys)">保存</a-button></template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item v-for="k in featureKeys" :key="k" :label="meta[k]?.label" :span="1">
<a-switch :checked="getVal(k)" @change="(val:boolean)=>setVal(k, val)" />
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="注册默认权限" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(permissionKeys)">保存</a-button></template>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item v-for="k in permissionKeys" :key="k" :label="meta[k]?.label">
<a-switch :checked="getVal(k)" @change="(val:boolean)=>setVal(k, val)" />
<span style="margin-left:10px;color:#8c8c8c">{{ meta[k]?.desc }}</span>
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="新用户 AI 对话额度" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(quotaDefaultKeys)">保存</a-button></template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="默认次数">
<a-input-number
:value="getVal('quota.default_ai_chat_limit')"
@change="(val:any)=>setVal('quota.default_ai_chat_limit', val)"
style="width:100%"
:min="0"
:max="1000000" />
<div style="color:#999;font-size:11px;margin-top:3px">0 表示不限次数</div>
</a-descriptions-item>
<a-descriptions-item label="重置周期">
<a-select
:value="getVal('quota.default_ai_chat_period') || 'day'"
@change="(val:string)=>setVal('quota.default_ai_chat_period', val)"
style="width:100%">
<a-select-option value="day">每天</a-select-option>
<a-select-option value="week">每周周一开始</a-select-option>
<a-select-option value="month">每月</a-select-option>
</a-select>
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="系统 & 品牌" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(systemKeys)">保存</a-button></template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item v-for="k in systemKeys" :key="k" :label="meta[k]?.label" :span="k==='brand.slogan'||k==='brand.logo_url'?2:1">
<a-input-number v-if="k==='system.max_ledgers_per_user'" :value="getVal(k)" @change="(val:any)=>setVal(k, val)" style="width:100%" :min="1" :max="50" />
<a-input v-else :value="getVal(k)" @change="(e:any)=>setVal(k, e.target.value)" />
</a-descriptions-item>
</a-descriptions>
</a-card>
</template>
</template>
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { message } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
interface Sticker { id: number; key: string; label: string; groupKey: string; triggerTags: string | null; imageUrl: string | null; isEnabled: boolean }
const list = ref<Sticker[]>([])
const loading = ref(true)
const modalVisible = ref(false)
const editing = ref<Sticker | null>(null)
const form = ref({ key: '', label: '', groupKey: 'classic', triggerTags: '', imageUrl: '', isEnabled: true })
const cols = [
{ title: 'Key', dataIndex: 'key', key: 'key', width: 110 },
{ title: '名称', dataIndex: 'label', key: 'label', width: 100 },
{ title: '分组', dataIndex: 'groupKey', key: 'group', width: 90 },
{ title: '触发标签', dataIndex: 'triggerTags', key: 'tags', width: 200 },
{ title: '状态', dataIndex: 'isEnabled', key: 'on', width: 70 },
{ title: '', key: 'act', width: 150 },
]
onMounted(refresh)
async function refresh() {
loading.value = true
try { list.value = await api.stickers() } finally { loading.value = false }
}
function openNew() {
editing.value = null
form.value = { key: '', label: '', groupKey: 'classic', triggerTags: '', imageUrl: '', isEnabled: true }
modalVisible.value = true
}
function openEdit(s: Sticker) {
editing.value = s
form.value = { key: s.key, label: s.label, groupKey: s.groupKey, triggerTags: s.triggerTags || '', imageUrl: s.imageUrl || '', isEnabled: s.isEnabled }
modalVisible.value = true
}
async function save() {
const data = { ...form.value, triggerTags: form.value.triggerTags || null, imageUrl: form.value.imageUrl || null }
if (editing.value) await api.updateSticker(editing.value.id, data)
else await api.createSticker(data)
message.success(editing.value ? '已更新' : '已创建')
modalVisible.value = false
refresh()
}
async function del(id: number) {
await api.deleteSticker(id)
message.success('已删除')
refresh()
}
</script>
<template>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>表情包库</h2>
<a-button type="primary" @click="openNew"><PlusOutlined /> 新建表情包</a-button>
</div>
<a-table :columns="cols" :dataSource="list" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tags'">
<template v-if="record.triggerTags">
<a-tag v-for="t in (record.triggerTags || '').split(',').filter(Boolean)" :key="t" color="blue" style="margin:1px 2px">{{ t }}</a-tag>
</template>
<span v-else style="color:#ccc">-</span>
</template>
<template v-if="column.key === 'on'">
<a-tag :color="record.isEnabled ? 'green' : 'default'">{{ record.isEnabled ? '启用' : '禁用' }}</a-tag>
</template>
<template v-if="column.key === 'act'">
<a-button size="small" style="margin-right:6px" @click="openEdit(record)"><EditOutlined /></a-button>
<a-popconfirm title="确定删除?" @confirm="del(record.id)">
<a-button size="small" danger><DeleteOutlined /></a-button>
</a-popconfirm>
</template>
</template>
</a-table>
<a-modal v-model:open="modalVisible" :title="editing ? '编辑表情包' : '新建表情包'" @ok="save" :width="520">
<a-form layout="vertical" style="margin-top:8px">
<a-row :gutter="12">
<a-col :span="12"><a-form-item label="Key"><a-input v-model:value="form.key" placeholder="salary" /></a-form-item></a-col>
<a-col :span="12"><a-form-item label="名称"><a-input v-model:value="form.label" placeholder="发工资啦" /></a-form-item></a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item label="分组">
<a-select v-model:value="form.groupKey">
<a-select-option value="ai_exclusive">🤖 AI 专属</a-select-option>
<a-select-option value="classic">📦 经典</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12"><a-form-item label="图片 URL"><a-input v-model:value="form.imageUrl" placeholder="可选 CDN 地址" /></a-form-item></a-col>
</a-row>
<a-form-item label="触发标签">
<a-input v-model:value="form.triggerTags" placeholder="over_budget,salary,forgive,逗号分隔" />
<div style="color:#999;font-size:11px;margin-top:4px">标签匹配用户场景AI 自动选择对应表情包</div>
</a-form-item>
<a-form-item label="是否启用"><a-switch v-model:checked="form.isEnabled" /></a-form-item>
</a-form>
</a-modal>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { message } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
interface Cat { id: number; name: string; iconKey: string; type: string; sortOrder: number; isDeleted: boolean }
const list = ref<Cat[]>([])
const loading = ref(true)
const modalVisible = ref(false)
const editing = ref<Cat | null>(null)
const form = ref({ name: '', iconKey: 'tag', type: 'expense' })
const iconOptions = [
{ value: 'food', label: '🍜 餐饮' }, { value: 'cup', label: '🥤 饮品' },
{ value: 'cart', label: '🛒 购物' }, { value: 'metro', label: '🚇 交通' },
{ value: 'house', label: '🏠 住房' }, { value: 'game', label: '🎮 娱乐' },
{ value: 'pill', label: '💊 医疗' }, { value: 'book', label: '📖 学习' },
{ value: 'shirt', label: '👕 服饰' }, { value: 'gift', label: '🎁 人情' },
{ value: 'plane', label: '✈️ 旅行' }, { value: 'tag', label: '🏷️ 其他' },
{ value: 'money', label: '💰 工资' }, { value: 'briefcase', label: '💼 兼职' },
{ value: 'chart', label: '📈 理财' }, { value: 'card', label: '💳 报销' },
{ value: 'sparkle', label: '✨ 奖金' },
]
const expenseCats = () => list.value.filter(c => c.type === 'Expense')
const incomeCats = () => list.value.filter(c => c.type === 'Income')
onMounted(refresh)
async function refresh() { loading.value = true; try { list.value = await api.sysCategories() } finally { loading.value = false } }
function openNew() { editing.value = null; form.value = { name: '', iconKey: 'tag', type: 'expense' }; modalVisible.value = true }
function openEdit(c: Cat) {
editing.value = c
form.value = { name: c.name, iconKey: c.iconKey, type: c.type === 'Income' ? 'income' : 'expense' }
modalVisible.value = true
}
async function save() {
const d = { name: form.value.name, iconKey: form.value.iconKey, type: form.value.type }
if (editing.value) await api.updateSysCategory(editing.value.id, d)
else await api.createSysCategory(d)
message.success(editing.value ? '已更新' : '已创建')
modalVisible.value = false
refresh()
}
async function del(id: number) { await api.deleteSysCategory(id); message.success('已删除(软删)'); refresh() }
</script>
<template>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>系统默认分类</h2>
<a-button type="primary" @click="openNew"><PlusOutlined /> 新建分类</a-button>
</div>
<p style="color:#999;font-size:12px;margin-bottom:14px">
此处管理新用户注册时获得的默认分类删除后已分配用户的分类不受影响软删除仅对新用户隐藏
</p>
<a-row :gutter="16">
<a-col :span="12">
<a-card title="💸 支出分类" size="small">
<a-table :columns="[
{ title: '排序', dataIndex: 'sortOrder', width: 55 },
{ title: '图标', key: 'icon', width: 55 },
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '', key: 'act', width: 90 },
]" :dataSource="expenseCats()" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'icon'">
<span style="font-size:18px">{{ iconOptions.find(i=>i.value===record.iconKey)?.label?.split(' ')[0] || '🏷️' }}</span>
</template>
<template v-if="column.key === 'act'">
<a-button size="small" type="link" @click="openEdit(record)"><EditOutlined /></a-button>
<a-popconfirm title="确定软删除?" @confirm="del(record.id)">
<a-button size="small" type="link" danger><DeleteOutlined /></a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
</a-col>
<a-col :span="12">
<a-card title="💰 收入分类" size="small">
<a-table :columns="[
{ title: '排序', dataIndex: 'sortOrder', width: 55 },
{ title: '图标', key: 'icon', width: 55 },
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '', key: 'act', width: 90 },
]" :dataSource="incomeCats()" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'icon'">
<span style="font-size:18px">{{ iconOptions.find(i=>i.value===record.iconKey)?.label?.split(' ')[0] || '🏷️' }}</span>
</template>
<template v-if="column.key === 'act'">
<a-button size="small" type="link" @click="openEdit(record)"><EditOutlined /></a-button>
<a-popconfirm title="确定软删除?" @confirm="del(record.id)">
<a-button size="small" type="link" danger><DeleteOutlined /></a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
<a-modal v-model:open="modalVisible" :title="editing ? '编辑分类' : '新建系统分类'" @ok="save" :width="400">
<a-form layout="vertical" style="margin-top:8px">
<a-form-item label="分类名称"><a-input v-model:value="form.name" placeholder="如:宠物" /></a-form-item>
<a-form-item label="图标">
<a-select v-model:value="form.iconKey" :options="iconOptions" />
</a-form-item>
<a-form-item label="类型">
<a-radio-group v-model:value="form.type">
<a-radio value="expense">💸 支出</a-radio>
<a-radio value="income">💰 收入</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</template>
+226
View File
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { message } from 'ant-design-vue'
import { ReloadOutlined } from '@ant-design/icons-vue'
import { formatShanghaiDate } from '../utils/time'
interface UserItem {
id: number; username: string; nickname: string | null; appMode: string;
isBanned: boolean; createdAt: string; lastLoginAt: string | null;
aiEnabled: boolean;
aiChatLimit: number; aiChatUsed: number; aiChatRemaining: number;
aiChatPeriod: 'day' | 'week' | 'month'; aiChatResetAt: string;
accountClosureRequestedAt: string | null; accountClosureScheduledAt: string | null;
companion: { avatarKey: string; personaKey: string; customName: string | null } | null;
txCount: number; aiTxCount: number;
}
const users = ref<UserItem[]>([])
const total = ref(0)
const loading = ref(true)
const search = ref('')
const page = ref(1)
const limit = 15
const cols = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username', key: 'un', width: 120 },
{ title: '模式', dataIndex: 'appMode', key: 'mode', width: 80 },
{ title: 'AI 权限', key: 'aiPermission', width: 100 },
{ title: 'AI 对话额度', key: 'aiQuota', width: 150 },
{ title: 'AI 伙伴', key: 'comp', width: 150 },
{ title: '账单(总/AI)', key: 'tx' },
{ title: '状态', key: 'status', width: 100 },
{ title: '注册时间', dataIndex: 'createdAt', key: 'reg', width: 110 },
{ title: '最后登录', dataIndex: 'lastLoginAt', key: 'login', width: 110 },
{ title: '', key: 'act', width: 250 },
]
// Stats modal
const statsVisible = ref(false)
const statsUser = ref('')
const statsData = ref<any>(null)
const quotaVisible = ref(false)
const quotaSaving = ref(false)
const quotaUser = ref<UserItem | null>(null)
const quotaLimit = ref(50)
const quotaPeriod = ref<'day' | 'week' | 'month'>('day')
const quotaResetUsage = ref(false)
const periodLabels = { day: '每天', week: '每周', month: '每月' }
const periodLabel = (value: UserItem['aiChatPeriod']) => periodLabels[value]
onMounted(refresh)
async function refresh() {
loading.value = true
try {
const res = await api.users({ search: search.value || undefined, page: page.value, limit })
users.value = res.list
total.value = res.total
} finally { loading.value = false }
}
async function doSearch() { page.value = 1; refresh() }
async function toggleBan(u: UserItem) {
const res = await api.toggleBan(u.id)
message.success(res.isBanned ? `已封禁 ${u.username}` : `已解封 ${u.username}`)
refresh()
}
async function toggleAiPermission(u: UserItem, enabled: boolean) {
const previous = u.aiEnabled
u.aiEnabled = enabled
try {
await api.updateUserPermissions(u.id, enabled)
message.success(enabled ? '已开放 AI 功能' : '已关闭该用户全部 AI 功能')
} catch {
u.aiEnabled = previous
message.error('AI 权限修改失败')
}
}
function editQuota(u: UserItem) {
quotaUser.value = u
quotaLimit.value = u.aiChatLimit
quotaPeriod.value = u.aiChatPeriod
quotaResetUsage.value = false
quotaVisible.value = true
}
async function saveQuota() {
const user = quotaUser.value
if (!user) return
quotaSaving.value = true
try {
await api.updateAiChatQuota(user.id, quotaLimit.value, quotaPeriod.value, quotaResetUsage.value)
message.success('AI 对话额度已更新')
quotaVisible.value = false
await refresh()
} catch {
message.error('AI 对话额度更新失败')
} finally {
quotaSaving.value = false
}
}
async function cancelClosure(u: UserItem) {
await api.cancelAccountClosure(u.id)
message.success('已取消 ' + u.username + ' 的注销流程')
refresh()
}
async function showStats(u: UserItem) {
statsUser.value = u.nickname || u.username
statsVisible.value = true
statsData.value = await api.userStats(u.id)
}
</script>
<template>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>用户管理</h2>
<a-button @click="refresh"><ReloadOutlined /> 刷新</a-button>
</div>
<div style="margin-bottom:14px;display:flex;gap:8px">
<a-input-search v-model:value="search" placeholder="搜索用户名..." style="max-width:280px" @search="doSearch" />
</div>
<a-table :columns="cols" :dataSource="users" :loading="loading" rowKey="id" size="small"
:pagination="{ current: page, total, pageSize: limit, showTotal: (t:number) => `共 ${t} 人`, onChange: (p:number) => { page=p; refresh() } }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'mode'">
<a-tag :color="record.appMode === 'ai' ? 'blue' : 'green'">{{ record.appMode === 'ai' ? '全AI' : '普通' }}</a-tag>
</template>
<template v-if="column.key === 'aiPermission'">
<a-switch
:checked="record.aiEnabled"
checked-children=""
un-checked-children=""
@change="(value:boolean) => toggleAiPermission(record, value)" />
</template>
<template v-if="column.key === 'aiQuota'">
<a-button type="link" size="small" @click="editQuota(record)" style="padding:0">
{{ record.aiChatUsed }}/{{ record.aiChatLimit === 0 ? '不限' : record.aiChatLimit }}
· {{ periodLabel(record.aiChatPeriod) }}
</a-button>
<div style="font-size:10px;color:#999">
{{ formatShanghaiDate(record.aiChatResetAt) }} 重置
</div>
</template>
<template v-if="column.key === 'comp'">
<span v-if="record.companion">形象:{{ record.companion.avatarKey }} · 性格:{{ record.companion.personaKey }}</span>
<span v-else style="color:#ccc">未设置</span>
</template>
<template v-if="column.key === 'tx'">
{{ record.txCount }} <span style="color:#999">(AI:{{ record.aiTxCount }})</span>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.accountClosureScheduledAt" color="orange">注销中</a-tag>
<a-tag v-else :color="record.isBanned ? 'red' : 'default'">{{ record.isBanned ? '已封' : '正常' }}</a-tag>
<div v-if="record.accountClosureScheduledAt" style="font-size:10px;color:#999;margin-top:3px">
{{ formatShanghaiDate(record.accountClosureScheduledAt) }} 删除
</div>
</template>
<template v-if="column.key === 'reg'">
{{ formatShanghaiDate(record.createdAt) }}
</template>
<template v-if="column.key === 'login'">
{{ record.lastLoginAt ? formatShanghaiDate(record.lastLoginAt) : '从未' }}
</template>
<template v-if="column.key === 'act'">
<a-button size="small" style="margin-right:6px" @click="editQuota(record)">额度</a-button>
<a-button size="small" style="margin-right:6px" @click="showStats(record)">📊 统计</a-button>
<a-popconfirm v-if="record.accountClosureScheduledAt" title="确定取消该用户的注销流程?" @confirm="cancelClosure(record)">
<a-button size="small" type="primary">取消注销</a-button>
</a-popconfirm>
<a-popconfirm v-else :title="record.isBanned ? '确定解封?' : '确定封禁?'" @confirm="toggleBan(record)">
<a-button size="small" :danger="!record.isBanned">{{ record.isBanned ? '解封' : '封禁' }}</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
<a-modal
v-model:open="quotaVisible"
title="调整 AI 对话额度"
:confirm-loading="quotaSaving"
ok-text="保存"
cancel-text="取消"
@ok="saveQuota">
<a-alert
v-if="quotaUser"
type="info"
:message="quotaUser.nickname || quotaUser.username"
show-icon
style="margin-bottom:14px" />
<a-form layout="vertical">
<a-form-item label="周期内可用次数">
<a-input-number v-model:value="quotaLimit" :min="0" :max="1000000" style="width:100%" />
<div style="font-size:11px;color:#999;margin-top:4px">设置为 0 表示不限次数</div>
</a-form-item>
<a-form-item label="重置周期">
<a-select v-model:value="quotaPeriod">
<a-select-option value="day">每天上海时间 00:00</a-select-option>
<a-select-option value="week">每周周一 00:00</a-select-option>
<a-select-option value="month">每月每月 1 00:00</a-select-option>
</a-select>
</a-form-item>
<a-checkbox v-model:checked="quotaResetUsage">立即将本周期已用次数清零</a-checkbox>
</a-form>
</a-modal>
<a-modal v-model:open="statsVisible" :title="`${statsUser} 使用统计`" :footer="null" :width="420">
<a-row :gutter="12" v-if="statsData">
<a-col :span="8"><a-card size="small"><a-statistic title="总账单" :value="statsData.totalTransactions" /></a-card></a-col>
<a-col :span="8"><a-card size="small"><a-statistic title="AI 记账" :value="statsData.aiBooked" /></a-card></a-col>
<a-col :span="8">
<a-card size="small">
<a-statistic title="AI 准确率" :value="statsData.aiAccuracy" suffix="%"
:value-style="{ color: statsData.aiAccuracy >= 70 ? '#00B386' : '#F0642D' }" />
</a-card>
</a-col>
</a-row>
</a-modal>
</template>
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true } },
},
build: { outDir: 'dist', assetsDir: 'assets' },
})
@@ -0,0 +1,532 @@
using System.IO.Compression;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json;
using MiaoJiZhang.Api.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.MySql;
namespace MiaoJiZhang.Api.Tests;
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture<ApiFixture>
{
public const string Name = "api";
}
public sealed class ApiFixture : IAsyncLifetime
{
private readonly MySqlContainer _database = new MySqlBuilder("mysql:8.4")
.WithDatabase("miaoji_test")
.WithUsername("miaoji_test")
.WithPassword("miaoji_test_password")
.WithCleanUp(true)
.Build();
public WebApplicationFactory<Program> Factory { get; private set; } = null!;
public async Task InitializeAsync()
{
await _database.StartAsync();
Environment.SetEnvironmentVariable(
"ConnectionStrings__Default",
_database.GetConnectionString());
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");
Factory = new WebApplicationFactory<Program>().WithWebHostBuilder(
builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureTestServices(services =>
{
services.RemoveAll<ILlmClient>();
services.AddSingleton<ILlmClient, NullLlmClient>();
});
});
using var client = Factory.CreateClient();
var ping = await client.GetAsync("/api/ping");
ping.EnsureSuccessStatusCode();
}
public async Task DisposeAsync()
{
Factory?.Dispose();
await _database.DisposeAsync();
Environment.SetEnvironmentVariable("ConnectionStrings__Default", null);
Environment.SetEnvironmentVariable("Jwt__Secret", null);
Environment.SetEnvironmentVariable("Admin__Key", null);
}
public async Task<HttpClient> RegisterAsync(string username)
{
var client = Factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/auth/register",
new { username, password = "test-password-123", agreedToTerms = true });
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
payload.GetProperty("token").GetString());
return client;
}
}
[Collection(ApiCollection.Name)]
public sealed class ApiIntegrationTests(ApiFixture fixture)
{
[Fact]
public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether()
{
using var owner = await fixture.RegisterAsync("owner_account");
using var stranger = await fixture.RegisterAsync("stranger_account");
var ledgers = await owner.GetFromJsonAsync<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var incomeCategories = await owner.GetFromJsonAsync<JsonElement>(
"/api/categories?type=income");
var expenseCategories = await owner.GetFromJsonAsync<JsonElement>(
"/api/categories?type=expense");
var incomeCategoryId = incomeCategories[0].GetProperty("id").GetInt64();
var expenseCategoryId = expenseCategories[0].GetProperty("id").GetInt64();
var forbiddenLedger = await stranger.PutAsJsonAsync(
$"/api/ledgers/{ledgerId}/default",
new { });
Assert.Equal(HttpStatusCode.NotFound, forbiddenLedger.StatusCode);
var mismatch = await owner.PostAsJsonAsync(
"/api/transactions",
new
{
ledgerId,
categoryId = expenseCategoryId,
type = "income",
amount = 100,
occurredAt = "2026-06-30T16:30:00Z",
});
Assert.Equal(HttpStatusCode.BadRequest, mismatch.StatusCode);
var createdResponse = await owner.PostAsJsonAsync(
"/api/transactions",
new
{
ledgerId,
categoryId = incomeCategoryId,
type = "income",
amount = 100,
note = "跨月收入",
occurredAt = "2026-06-30T16:30:00Z",
});
createdResponse.EnsureSuccessStatusCode();
var created = await createdResponse.Content.ReadFromJsonAsync<JsonElement>();
var transactionId = created.GetProperty("id").GetInt64();
var july = await owner.GetFromJsonAsync<JsonElement>(
$"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
Assert.Equal(1, july.GetProperty("count").GetInt32());
Assert.Equal(100m, july.GetProperty("income").GetDecimal());
Assert.Equal(0m, july.GetProperty("expense").GetDecimal());
var persistedOccurredAt = july.GetProperty("days")[0]
.GetProperty("items")[0]
.GetProperty("occurredAt")
.GetString();
Assert.EndsWith("Z", persistedOccurredAt, StringComparison.Ordinal);
var recurringBudget = await owner.PutAsJsonAsync(
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
new
{
categoryId = expenseCategoryId,
amount = 800,
recurring = true,
});
recurringBudget.EnsureSuccessStatusCode();
var augustBudget = await owner.GetFromJsonAsync<JsonElement>(
$"/api/budgets?year=2026&month=8&ledgerId={ledgerId}");
Assert.True(
augustBudget.GetProperty("categories")[0]
.GetProperty("isRecurring")
.GetBoolean());
var delete = await owner.DeleteAsync(
$"/api/transactions/{transactionId}");
Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
var recycle = await owner.GetFromJsonAsync<JsonElement>(
$"/api/transactions/recycle-bin?ledgerId={ledgerId}");
Assert.Single(recycle.EnumerateArray());
var restore = await owner.PostAsync(
$"/api/transactions/{transactionId}/restore",
null);
restore.EnsureSuccessStatusCode();
recycle = await owner.GetFromJsonAsync<JsonElement>(
$"/api/transactions/recycle-bin?ledgerId={ledgerId}");
Assert.Empty(recycle.EnumerateArray());
var export = await owner.GetByteArrayAsync("/api/users/me/export");
using var archive = new ZipArchive(new MemoryStream(export));
Assert.Contains(archive.Entries, entry => entry.Name == "transactions.csv");
Assert.Contains(archive.Entries, entry => entry.Name == "budgets.csv");
var backupEntry = Assert.Single(
archive.Entries, entry => entry.Name == "backup.json");
using var reader = new StreamReader(backupEntry.Open());
var backup = await reader.ReadToEndAsync();
Assert.DoesNotContain(
"passwordHash",
backup,
StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(
"apiKey",
backup,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AccountClosure_RevokesOldToken_AndLoginCancelsDuringGracePeriod()
{
using var client = await fixture.RegisterAsync("closure_account");
var closure = await client.PostAsJsonAsync(
"/api/users/me/closure",
new
{
password = "test-password-123",
confirmationText = "注销账号",
});
Assert.Equal(HttpStatusCode.Accepted, closure.StatusCode);
var oldSession = await client.GetAsync("/api/users/me");
Assert.Equal(HttpStatusCode.Unauthorized, oldSession.StatusCode);
using var restored = fixture.Factory.CreateClient();
var login = await restored.PostAsJsonAsync(
"/api/auth/login",
new
{
username = "closure_account",
password = "test-password-123",
});
login.EnsureSuccessStatusCode();
var payload = await login.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(payload.GetProperty("accountClosureCancelled").GetBoolean());
restored.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
payload.GetProperty("token").GetString());
Assert.Equal(
HttpStatusCode.OK,
(await restored.GetAsync("/api/users/me")).StatusCode);
}
[Fact]
public async Task CategoryReorder_RejectsUnknownIcons_AndReportsUseLedgerPeriod()
{
using var client = await fixture.RegisterAsync("report_account");
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var invalid = await client.PostAsJsonAsync(
"/api/categories",
new { name = "未知图标", iconKey = "not-real", type = "expense" });
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
async Task<long> CreateCategory(string name, string iconKey)
{
var response = await client.PostAsJsonAsync(
"/api/categories",
new { name, iconKey, type = "expense" });
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("id").GetInt64();
}
var firstId = await CreateCategory("养车", "car");
var secondId = await CreateCategory("宠物", "pet");
var reorder = await client.PutAsJsonAsync(
"/api/categories/reorder",
new { type = "expense", categoryIds = new[] { secondId, firstId } });
Assert.Equal(HttpStatusCode.NoContent, reorder.StatusCode);
var categories = await client.GetFromJsonAsync<JsonElement>(
"/api/categories?type=expense");
var customIds = categories.EnumerateArray()
.Where(item => item.GetProperty("isCustom").GetBoolean())
.Select(item => item.GetProperty("id").GetInt64())
.ToArray();
Assert.Equal(new[] { secondId, firstId }, customIds);
var weekly = await client.GetAsync(
$"/api/reports/weekly?date=2026-07-19&ledgerId={ledgerId}");
weekly.EnsureSuccessStatusCode();
var yearly = await client.GetAsync(
$"/api/reports/yearly?year=2026&ledgerId={ledgerId}");
yearly.EnsureSuccessStatusCode();
}
[Fact]
public async Task RegistrationDefaultAndUserAiPermission_AreEnforcedImmediately()
{
async Task SetDefaultAsync(bool enabled)
{
await using var scope = fixture.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<
MiaoJiZhang.Infrastructure.Persistence.AppDbContext>();
var config = await db.AppConfigs.SingleAsync(item =>
item.Key == "permission.default.ai_enabled");
config.Value = enabled ? "true" : "false";
config.Version++;
config.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
await SetDefaultAsync(false);
try
{
using var client = await fixture.RegisterAsync("permission_default_off");
var profile = await client.GetFromJsonAsync<JsonElement>("/api/users/me");
Assert.False(
profile.GetProperty("permissions").GetProperty("ai").GetBoolean());
var denied = await client.PostAsJsonAsync(
"/api/chat/messages",
new { content = "本月花了多少" });
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
var error = await denied.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(
"AI_PERMISSION_DENIED",
error.GetProperty("code").GetString());
}
finally
{
await SetDefaultAsync(true);
}
}
[Fact]
public async Task AiChatQuota_IsBoundToUserAndUsesConfiguredPeriod()
{
using var client = await fixture.RegisterAsync("ai_quota_account");
var profile = await client.GetFromJsonAsync<JsonElement>("/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");
var update = await admin.PutAsJsonAsync(
$"/api/admin/users/{userId}/ai-quota",
new { limit = 1, period = "week", resetUsage = true });
update.EnsureSuccessStatusCode();
var first = await client.PostAsJsonAsync(
"/api/chat/messages",
new { content = "本周花了多少" });
first.EnsureSuccessStatusCode();
var denied = await client.PostAsJsonAsync(
"/api/chat/messages",
new { content = "再查一次本周支出" });
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
var error = await denied.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(
"AI_CHAT_QUOTA_EXCEEDED",
error.GetProperty("code").GetString());
Assert.Equal(
"week",
error.GetProperty("quota").GetProperty("period").GetString());
profile = await client.GetFromJsonAsync<JsonElement>("/api/users/me");
var quota = profile.GetProperty("aiChatQuota");
Assert.Equal(1, quota.GetProperty("limit").GetInt32());
Assert.Equal(1, quota.GetProperty("used").GetInt32());
Assert.Equal(0, quota.GetProperty("remaining").GetInt32());
Assert.Equal("week", quota.GetProperty("period").GetString());
}
[Fact]
public async Task RecognitionClientRequestId_IsIdempotentAcrossConcurrentChannels()
{
using var client = await fixture.RegisterAsync("recognition_idempotency");
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var categories = await client.GetFromJsonAsync<JsonElement>(
"/api/categories?type=expense");
var categoryId = categories[0].GetProperty("id").GetInt64();
const string clientRequestId = "wx-order-20260721-001";
var payload = new
{
ledgerId,
categoryId,
type = "expense",
amount = 28.50m,
note = "微信支付",
occurredAt = "2026-07-21T10:30:00+08:00",
source = "local_ocr",
clientRequestId,
};
var responses = await Task.WhenAll(
client.PostAsJsonAsync("/api/transactions", payload),
client.PostAsJsonAsync("/api/transactions", payload));
Assert.All(responses, response => response.EnsureSuccessStatusCode());
var created = await Task.WhenAll(
responses.Select(response =>
response.Content.ReadFromJsonAsync<JsonElement>()));
Assert.Equal(
created[0].GetProperty("id").GetInt64(),
created[1].GetProperty("id").GetInt64());
Assert.Equal("local_ocr", created[0].GetProperty("source").GetString());
var month = await client.GetFromJsonAsync<JsonElement>(
$"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
Assert.Equal(1, month.GetProperty("count").GetInt32());
}
}
public sealed class ChinaClockTests
{
[Fact]
public void ShanghaiMonthRange_MapsUtcBoundaryCorrectly()
{
var (start, end) = ChinaClock.MonthRangeUtc(2026, 7);
Assert.Equal(
new DateTime(2026, 6, 30, 16, 0, 0, DateTimeKind.Utc),
start);
Assert.Equal(
new DateTime(2026, 7, 31, 16, 0, 0, DateTimeKind.Utc),
end);
}
[Fact]
public void ShanghaiWeekAndYearRanges_UseLocalCalendarBoundaries()
{
var (weekStart, weekEnd) = ChinaClock.WeekRangeUtc(
new DateTime(2026, 7, 19));
Assert.Equal(
new DateTime(2026, 7, 12, 16, 0, 0, DateTimeKind.Utc),
weekStart);
Assert.Equal(
new DateTime(2026, 7, 19, 16, 0, 0, DateTimeKind.Utc),
weekEnd);
var (yearStart, yearEnd) = ChinaClock.YearRangeUtc(2026);
Assert.Equal(
new DateTime(2025, 12, 31, 16, 0, 0, DateTimeKind.Utc),
yearStart);
Assert.Equal(
new DateTime(2026, 12, 31, 16, 0, 0, DateTimeKind.Utc),
yearEnd);
}
}
public sealed class ImageParseResultTests
{
private static readonly MethodInfo ParseMethod =
typeof(OpenAiVisionClient).GetMethod(
"ParseImageResults",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("Image parser not found");
[Fact]
public void FullTimes_AreParsedPerBill_AndDateOnlyFallsBack()
{
const string payload =
"""
{
"bills": [
{
"type": "expense",
"amount": 18,
"categoryName": "饮品",
"note": "奶茶",
"occurredAt": "2026-07-18T14:30:00+08:00"
},
{
"type": "income",
"amount": 100,
"categoryName": "兼职",
"note": "稿费",
"occurredAt": "2026-07-17T09:15:00+08:00"
},
{
"type": "expense",
"amount": 30,
"categoryName": "餐饮",
"note": "午饭",
"occurredAt": "2026-07-16"
}
]
}
""";
var results = Assert.IsAssignableFrom<IReadOnlyList<ImageParseResult>>(
ParseMethod.Invoke(null, [payload]));
Assert.Equal(3, results.Count);
Assert.Equal(
new DateTime(2026, 7, 18, 6, 30, 0, DateTimeKind.Utc),
results[0].OccurredAt);
Assert.Equal(
new DateTime(2026, 7, 17, 1, 15, 0, DateTimeKind.Utc),
results[1].OccurredAt);
Assert.Null(results[2].OccurredAt);
}
}
public sealed class BudgetRecommendationValidationTests
{
[Fact]
public void Draft_IsClampedToSpent_AndKeepsUnmentionedCategories()
{
var contexts =
new Dictionary<long, BudgetRecommendationService.CategoryContext>
{
[1] = new(1, "餐饮", "food", "coral", 80, 120, "high"),
[2] = new(2, "交通", "transport", "teal", 20, 60, "medium"),
};
var request = new MiaoJiZhang.Api.Contracts.RefineBudgetRecommendationRequest(
null,
2026,
7,
"餐饮降到十元,其他不变",
new MiaoJiZhang.Api.Contracts.BudgetDraftRequest(
150,
[
new(1, 100),
new(2, 50),
]));
const string arguments =
"""
{
"suggestedTotal": 20,
"items": [
{ "categoryId": 1, "amount": 10 }
],
"summary": "已降低餐饮预算"
}
""";
var result = BudgetRecommendationService.ValidateToolDraft(
request,
contexts,
new Dictionary<long, decimal> { [1] = 100, [2] = 50 },
100,
arguments,
out var summary);
Assert.Equal("已降低餐饮预算", summary);
Assert.Equal(130, result.SuggestedTotal);
Assert.Equal(80, result.Items.Single(item => item.CategoryId == 1).SuggestedAmount);
Assert.Equal(50, result.Items.Single(item => item.CategoryId == 2).SuggestedAmount);
Assert.Contains(result.Warnings!, warning => warning.Contains("不能低于已花"));
Assert.Contains(result.Warnings!, warning => warning.Contains("保留原值"));
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Testcontainers.MySql" Version="4.13.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MiaoJiZhang.Api\MiaoJiZhang.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
namespace MiaoJiZhang.Api.Contracts;
// ---- Auth(当前仅用户名密码;快捷登录接口预留不实现)----
public record RegisterRequest(string Username, string Password, bool AgreedToTerms = false);
public record LoginRequest(string Username, string Password);
public record AuthResponse(long UserId, string Username, string Token, DateTime ExpiresAt, bool AccountClosureCancelled = false);
// ---- Onboarding ----
public record OnboardingRequest(string AppMode, string AvatarKey, string PersonaKey, string? CustomName);
public record SwitchModeRequest(string AppMode);
public record UpdateCompanionRequest(string? AvatarKey, string? PersonaKey, string? CustomName, int? RoastLevel, int? StickerFrequency, int? ProactiveLevel);
// ---- User profile ----
public record AiCompanionDto(string AvatarKey, string PersonaKey, string? CustomName, int RoastLevel, int StickerFrequency, int ProactiveLevel);
public record UserPermissionsDto(bool Ai);
public record AiChatQuotaDto(int Limit, int Used, int Remaining, string Period, DateTime ResetAt);
public record UserProfileResponse(long UserId, string Username, string? Nickname, string AppMode, AiCompanionDto? AiCompanion, bool OnboardingDone, UserPermissionsDto Permissions, AiChatQuotaDto AiChatQuota);
public record UpdateProfileRequest(string? Nickname);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record DeleteAccountRequest(string Password, string? ConfirmationText = null);
public record AccountClosureResponse(DateTime RequestedAt, DateTime ScheduledAt);
public record UpdateUserPermissionsRequest(bool Ai);
public record UpdateAiChatQuotaRequest(int Limit, string Period, bool ResetUsage = false);
// ---- 通用 ----
public record ApiError(string Code, string Message);
@@ -0,0 +1,147 @@
namespace MiaoJiZhang.Api.Contracts;
// ---- Ledger / Category ----
public record LedgerDto(long Id, string Name, string IconKey, bool IsDefault);
public record CategoryDto(
long Id, string Name, string IconKey, string Type,
int SortOrder, bool IsCustom, string ColorKey = "mint");
public record CreateCategoryRequest(
string Name, string IconKey, string Type, string? ColorKey = null);
public record UpdateCategoryRequest(
string Name, string IconKey, int SortOrder, string? ColorKey = null);
// ---- Transaction ----
public record CreateTransactionRequest(
long? LedgerId, // null = 默认账本
long CategoryId,
string Type, // expense | income
decimal Amount,
string? Note,
string? PaymentMethod,
DateTime? OccurredAt, // null = 现在
string? Source, // manual | voice | ocr
string? SourceText,
string? ClientRequestId = null);
public record UpdateTransactionRequest(
long LedgerId,
long CategoryId,
string Type,
decimal Amount,
string? Note,
string? PaymentMethod,
DateTime OccurredAt,
DateTime? BaseUpdatedAt = 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);
public record DailyGroupDto(DateOnly Date, decimal Expense, decimal Income, List<TransactionDto> Items);
public record MonthSummaryDto(
int Year, int Month, decimal Income, decimal Expense, decimal Balance,
int Count, List<DailyGroupDto> Days);
public record CategoryStatDto(
long CategoryId, string Name, string IconKey,
decimal Amount, double Percent, string ColorKey = "mint");
public record MonthStatsDto(int Year, int Month, decimal TotalExpense, decimal TotalIncome, List<CategoryStatDto> ByCategory, List<decimal> DailyExpenses)
{
public string? AiAnalysis { get; set; }
}
public record PeriodTrendPointDto(
string Label, DateTime Date, decimal Expense, decimal Income);
public record PeriodStatsDto(
string PeriodType, string PeriodLabel, DateTime StartDate, DateTime EndDate,
decimal TotalExpense, decimal TotalIncome, decimal Balance, int Count,
List<CategoryStatDto> ByCategory, List<PeriodTrendPointDto> Trend,
string? Analysis);
// ---- Budget ----
public record BudgetItemDto(
long? CategoryId, string? CategoryName, string? CategoryIcon,
decimal Amount, decimal Spent, bool IsRecurring,
string? CategoryColor = null);
public record BudgetsResponse(int Year, int Month, BudgetItemDto? Total, List<BudgetItemDto> Categories);
public record UpsertBudgetRequest(long? CategoryId, decimal Amount, bool Recurring = false); // Amount<=0 表示删除该预算
public record BudgetAmountRequest(long? CategoryId, decimal Amount);
public record ApplyBudgetBatchRequest(bool Recurring, List<BudgetAmountRequest> Items);
public record BudgetRecommendationItemDto(
long CategoryId,
string CategoryName,
string CategoryIcon,
decimal SuggestedAmount,
decimal CurrentSpent,
decimal HistoricalAverage,
string Confidence,
string ColorKey = "mint");
public record BudgetRecommendationResponse(
int Year,
int Month,
decimal SuggestedTotal,
string Message,
List<BudgetRecommendationItemDto> Items,
string? AdjustmentSummary = null,
List<string>? Warnings = null);
public record BudgetDraftItemRequest(long CategoryId, decimal Amount);
public record BudgetDraftRequest(
decimal SuggestedTotal,
List<BudgetDraftItemRequest> Items);
public record BudgetRefinementTurn(string Role, string Content);
public record RefineBudgetRecommendationRequest(
long? LedgerId,
int Year,
int Month,
string Instruction,
BudgetDraftRequest CurrentDraft,
List<BudgetRefinementTurn>? History = null);
// ---- Sticker ----
public record StickerDto(string Key, string Label, string GroupKey);
// ---- Monthly report ----
public record MonthlyReportDto(
int Year, int Month, decimal Income, decimal Expense, decimal Balance, int Count,
double AiRatio,
string? TopDay, decimal TopDayAmount, string? TopDayNote,
string? TopCategory, decimal TopCategoryAmount, double TopCategoryPercent,
List<CategoryRankDto> CategoryRanking,
string Roast);
public record CategoryRankDto(
string Name, string IconKey, decimal Amount, double Percent,
string ColorKey = "mint");
public record PeriodReportDto(
string PeriodType, string PeriodLabel, DateTime StartDate, DateTime EndDate,
decimal Income, decimal Expense, decimal Balance, int Count, double AiRatio,
string? PeakLabel, decimal PeakAmount, string? PeakNote,
string? TopCategory, decimal TopCategoryAmount, double TopCategoryPercent,
List<CategoryRankDto> CategoryRanking,
string Commentary);
// ---- OCR ----
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);
public record ImageParseItemResponse(
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
string Type, decimal Amount, string? PaymentMethod, string Note,
DateTime? OccurredAt);
public record ImageParseResponse(
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
decimal Amount, string? PaymentMethod, string Note, string Type,
List<ImageParseItemResponse> Items, DateTime? OccurredAt);
// ---- Chat ----
public record SendChatRequest(string Content, string Type = "text", long? LedgerId = null); // text | sticker
public record ChatMessageDto(
long Id, string Role, string Type, string Content,
TransactionDto? Transaction, DateTime CreatedAt);
public record SendChatResponse(List<ChatMessageDto> Messages);
@@ -0,0 +1,280 @@
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[AdminAuth]
[Route("api/admin")]
public class AdminController(AppDbContext db) : ControllerBase
{
[HttpGet("dashboard")] public async Task<IActionResult> Dashboard() { var tu = await db.Users.CountAsync(); var ta = await db.Users.CountAsync(u => u.LastLoginAt.HasValue && u.LastLoginAt.Value >= ChinaClock.ToUtc(ChinaClock.Now.Date) && u.LastLoginAt.Value < ChinaClock.ToUtc(ChinaClock.Now.Date.AddDays(1))); var tt = await db.Transactions.IgnoreQueryFilters().CountAsync(); var ai = await db.Transactions.IgnoreQueryFilters().CountAsync(t => TransactionSourceRules.AiAssisted.Contains(t.Source)); var at = await db.Transactions.IgnoreQueryFilters().Where(t => TransactionSourceRules.AiAssisted.Contains(t.Source)).CountAsync(); var ud = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { users = new { total = tu, activeToday = ta }, transactions = new { total = tt, aiBooked = ai }, aiAccuracy = at == 0 ? 0 : Math.Round((1.0 - (double)ud / at) * 100, 1), undoRate = at == 0 ? 0 : Math.Round((double)ud / at * 100, 1), aiMessages = await db.ChatMessages.CountAsync(m => m.Role == ChatRole.Assistant) }); }
[HttpGet("configs")]
public async Task<IActionResult> ListConfigs()
{
var configs = await db.AppConfigs.OrderBy(c => c.Key).ToListAsync();
return Ok(configs.Select(c => new
{
c.Id,
c.Key,
Value = IsSecret(c.Key) ? "********" : c.Value,
IsSecret = IsSecret(c.Key),
c.Version,
c.UpdatedAt,
}));
}
[HttpPut("configs/{id:long}")]
public async Task<IActionResult> UpdateConfig(long id, [FromBody] UpdateConfigRequest req)
{
var cfg = await db.AppConfigs.FindAsync(id);
if (cfg is null) return NotFound();
if (IsSecret(cfg.Key))
return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量修改" });
cfg.Value = req.Value;
cfg.Version++;
cfg.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
}
[HttpPost("configs")]
public async Task<IActionResult> CreateConfig([FromBody] CreateConfigRequest req)
{
if (string.IsNullOrWhiteSpace(req.Key)) return BadRequest(new { error = "key_required" });
if (IsSecret(req.Key))
return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量配置" });
if (await db.AppConfigs.AnyAsync(c => c.Key == req.Key))
return Conflict(new { error = "key_exists", detail = "该配置 Key 已存在,请用 PUT 更新" });
var cfg = new AppConfig { Key = req.Key.Trim(), Value = req.Value ?? "", Version = 1, UpdatedAt = DateTime.UtcNow };
db.AppConfigs.Add(cfg);
await db.SaveChangesAsync();
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
}
[HttpGet("personas")] public async Task<IActionResult> ListPersonas() => Ok(await db.AiPersonas.OrderBy(p => p.Key).ToListAsync());
[HttpPost("personas")] public async Task<IActionResult> CreatePersona([FromBody] UpsertPersonaRequest req) { if (await db.AiPersonas.AnyAsync(p => p.Key == req.Key)) return Conflict(new { error = "key_exists" }); var p = new AiPersona { Key = req.Key, Name = req.Name, Description = req.Description, SampleLine = req.SampleLine, PromptTemplate = req.PromptTemplate, IsEnabled = true, Version = 1 }; db.AiPersonas.Add(p); await db.SaveChangesAsync(); return Ok(p); }
[HttpPut("personas/{id:long}")] public async Task<IActionResult> UpdatePersona(long id, [FromBody] UpsertPersonaRequest req) { var p = await db.AiPersonas.FindAsync(id); if (p is null) return NotFound(); p.Key = req.Key; p.Name = req.Name; p.Description = req.Description; p.SampleLine = req.SampleLine; p.PromptTemplate = req.PromptTemplate; p.IsEnabled = req.IsEnabled; p.Version++; await db.SaveChangesAsync(); return Ok(p); }
[HttpDelete("personas/{id:long}")] public async Task<IActionResult> DeletePersona(long id) { var p = await db.AiPersonas.FindAsync(id); if (p is null) return NotFound(); db.AiPersonas.Remove(p); await db.SaveChangesAsync(); return NoContent(); }
[HttpGet("avatars")] public async Task<IActionResult> ListAvatars() => Ok(await db.AiAvatars.OrderBy(a => a.Key).ToListAsync());
[HttpPost("avatars")] public async Task<IActionResult> CreateAvatar([FromBody] UpsertAvatarRequest req) { if (await db.AiAvatars.AnyAsync(a => a.Key == req.Key)) return Conflict(new { error = "key_exists" }); var a = new AiAvatar { Key = req.Key, DefaultName = req.Name, SpeechTic = req.SpeechTic, ImageUrl = req.ImageUrl, IsEnabled = true }; db.AiAvatars.Add(a); await db.SaveChangesAsync(); return Ok(a); }
[HttpPut("avatars/{id:long}")] public async Task<IActionResult> UpdateAvatar(long id, [FromBody] UpsertAvatarRequest req) { var a = await db.AiAvatars.FindAsync(id); if (a is null) return NotFound(); a.Key = req.Key; a.DefaultName = req.Name; a.SpeechTic = req.SpeechTic; a.ImageUrl = req.ImageUrl; a.IsEnabled = req.IsEnabled; await db.SaveChangesAsync(); return Ok(a); }
[HttpDelete("avatars/{id:long}")] public async Task<IActionResult> DeleteAvatar(long id) { var a = await db.AiAvatars.FindAsync(id); if (a is null) return NotFound(); db.AiAvatars.Remove(a); await db.SaveChangesAsync(); return NoContent(); }
[HttpGet("stickers")] public async Task<IActionResult> ListStickers() => Ok(await db.Stickers.OrderBy(s => s.GroupKey).ThenBy(s => s.Key).ToListAsync());
[HttpPost("stickers")] public async Task<IActionResult> CreateSticker([FromBody] UpsertStickerRequest req) { if (await db.Stickers.AnyAsync(s => s.Key == req.Key)) return Conflict(new { error = "key_exists" }); var s = new Sticker { Key = req.Key, Label = req.Label, GroupKey = req.GroupKey, TriggerTags = req.TriggerTags, ImageUrl = req.ImageUrl, IsEnabled = true }; db.Stickers.Add(s); await db.SaveChangesAsync(); return Ok(s); }
[HttpPut("stickers/{id:long}")] public async Task<IActionResult> UpdateSticker(long id, [FromBody] UpsertStickerRequest req) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); s.Key = req.Key; s.Label = req.Label; s.GroupKey = req.GroupKey; s.TriggerTags = req.TriggerTags; s.ImageUrl = req.ImageUrl; s.IsEnabled = req.IsEnabled; await db.SaveChangesAsync(); return Ok(s); }
[HttpDelete("stickers/{id:long}")] public async Task<IActionResult> DeleteSticker(long id) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); db.Stickers.Remove(s); await db.SaveChangesAsync(); return NoContent(); }
[HttpPost("llm/test")] public async Task<IActionResult> TestLlm([FromServices] ILlmClient llm) { var (ok, error) = await llm.TestConnectionAsync(); return Ok(new { ok, error = ok ? (string?)null : error, detail = ok ? "LLM 连接正常" : error }); }
[HttpPost("configs/init")] public async Task<IActionResult> InitConfigs() { var now = DateTime.UtcNow; var defaults = new Dictionary<string, string> { ["brand.app_name"] = "记之", ["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱", ["brand.logo_url"] = "", ["llm.protocol"] = "responses", ["llm.base_url"] = "https://api.openai.com/v1", ["llm.model"] = "gpt-4o-mini", ["llm.max_tokens"] = "1024", ["llm.temperature"] = "0.8", ["limit.daily_ai_messages"] = "200", ["limit.daily_ai_messages_per_user"] = "50", ["limit.max_monthly_budget"] = "99999999", ["feature.ocr_enabled"] = "true", ["feature.voice_enabled"] = "true", ["feature.ai_auto_book"] = "true", ["feature.sticker_enabled"] = "true", ["system.default_ledger_name"] = "日常账本", ["system.max_ledgers_per_user"] = "10", ["feature.screenshot_bookkeeping_enabled"] = "true", ["permission.default.ai_enabled"] = "true", ["quota.default_ai_chat_limit"] = "50", ["quota.default_ai_chat_period"] = "day" }; var existing = await db.AppConfigs.Select(c => c.Key).ToListAsync(); var added = 0; foreach (var (key, value) in defaults) { if (!existing.Contains(key)) { db.AppConfigs.Add(new AppConfig { Key = key, Value = value, Version = 1, UpdatedAt = now }); added++; } } if (added > 0) await db.SaveChangesAsync(); return Ok(new { added, total = defaults.Count }); }
[HttpGet("categories")] public async Task<IActionResult> ListSystemCategories() => Ok(await db.Categories.Where(c => c.UserId == null && !c.IsDeleted).OrderBy(c => c.Type).ThenBy(c => c.SortOrder).ToListAsync());
[HttpPost("categories")]
public async Task<IActionResult> CreateSystemCategory([FromBody] UpsertCategoryRequest req)
{
if (req.Type is not ("income" or "expense"))
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
var iconKey = CategoriesController.NormalizeIcon(req.IconKey);
if (!CategoriesController.AllowedIcons.Contains(iconKey))
return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在"));
var type = req.Type == "income" ? TransactionType.Income : TransactionType.Expense;
var maxSort = await db.Categories
.Where(category => category.UserId == null && category.Type == type)
.MaxAsync(category => (int?)category.SortOrder) ?? 0;
var category = new Category
{
Type = type,
Name = req.Name,
IconKey = iconKey,
SortOrder = maxSort + 1,
};
db.Categories.Add(category);
await db.SaveChangesAsync();
return Ok(category);
}
[HttpPut("categories/{id:long}")]
public async Task<IActionResult> UpdateSystemCategory(long id, [FromBody] UpsertCategoryRequest req)
{
if (req.Type is not ("income" or "expense"))
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
var iconKey = CategoriesController.NormalizeIcon(req.IconKey);
if (!CategoriesController.AllowedIcons.Contains(iconKey))
return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在"));
var category = await db.Categories.FirstOrDefaultAsync(
item => item.Id == id && item.UserId == null);
if (category is null) return NotFound();
category.Name = req.Name;
category.IconKey = iconKey;
category.Type = req.Type == "income"
? TransactionType.Income
: TransactionType.Expense;
await db.SaveChangesAsync();
return Ok(category);
}
[HttpDelete("categories/{id:long}")] public async Task<IActionResult> DeleteSystemCategory(long id) { var cat = await db.Categories.FirstOrDefaultAsync(c => c.Id == id && c.UserId == null); if (cat is null) return NotFound(); cat.IsDeleted = true; await db.SaveChangesAsync(); return NoContent(); }
[HttpGet("users")]
public async Task<IActionResult> ListUsers(
[FromQuery] string? search,
[FromQuery] bool? banned,
[FromQuery] int page = 1,
[FromQuery] int limit = 20)
{
var query = db.Users
.Include(user => user.AiCompanion)
.Include(user => user.FeaturePermissions)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(user => user.Username.Contains(search));
if (banned.HasValue)
query = query.Where(user => user.IsBanned == banned.Value);
var total = await query.CountAsync();
var users = await query
.OrderByDescending(user => user.CreatedAt)
.Skip((Math.Max(1, page) - 1) * Math.Clamp(limit, 1, 100))
.Take(Math.Clamp(limit, 1, 100))
.ToListAsync();
var ids = users.Select(user => user.Id).ToArray();
var transactionCounts = await db.Transactions
.IgnoreQueryFilters()
.Where(transaction => ids.Contains(transaction.UserId))
.GroupBy(transaction => transaction.UserId)
.Select(group => new { UserId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.UserId, item => item.Count);
var aiTransactionCounts = await db.Transactions
.IgnoreQueryFilters()
.Where(transaction =>
ids.Contains(transaction.UserId) &&
TransactionSourceRules.AiAssisted.Contains(transaction.Source))
.GroupBy(transaction => transaction.UserId)
.Select(group => new { UserId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.UserId, item => item.Count);
var list = users.Select(user =>
{
var quota = AiChatQuotaService.GetStatus(user);
return new
{
user.Id,
user.Username,
user.Nickname,
AppMode = user.AppMode == AppMode.AiFirst ? "ai" : "normal",
user.IsBanned,
user.CreatedAt,
user.LastLoginAt,
user.AccountClosureRequestedAt,
user.AccountClosureScheduledAt,
AiEnabled = user.FeaturePermissions.Any(permission =>
permission.PermissionKey == FeaturePermissionKeys.Ai &&
permission.IsEnabled),
AiChatLimit = quota.Limit,
AiChatUsed = quota.Used,
AiChatRemaining = quota.Remaining,
AiChatPeriod = AiChatQuotaService.PeriodKey(quota.Period),
AiChatResetAt = quota.ResetAt,
Companion = user.AiCompanion == null
? null
: new
{
user.AiCompanion.AvatarKey,
user.AiCompanion.PersonaKey,
user.AiCompanion.CustomName,
},
TxCount = transactionCounts.GetValueOrDefault(user.Id),
AiTxCount = aiTransactionCounts.GetValueOrDefault(user.Id),
};
}).ToList();
return Ok(new { total, page, list });
}
[HttpPut("users/{id:long}/ban")] public async Task<IActionResult> ToggleBan(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); u.IsBanned = !u.IsBanned; u.AuthVersion++; await db.SaveChangesAsync(); return Ok(new { u.Id, u.Username, u.IsBanned }); }
[HttpPut("users/{id:long}/cancel-closure")] public async Task<IActionResult> CancelClosure(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); if (!u.AccountClosureScheduledAt.HasValue) return Conflict(new ApiError("CLOSURE_NOT_PENDING", "账号未处于注销流程")); u.AccountClosureRequestedAt = null; u.AccountClosureScheduledAt = null; u.AuthVersion++; await db.SaveChangesAsync(); return Ok(new { u.Id, u.Username, cancelled = true }); }
[HttpPut("users/{id:long}/permissions")]
public async Task<IActionResult> UpdatePermissions(long id, UpdateUserPermissionsRequest req)
{
var user = await db.Users.FindAsync(id);
if (user is null) return NotFound();
var permission = await db.UserFeaturePermissions.FirstOrDefaultAsync(p =>
p.UserId == id && p.PermissionKey == FeaturePermissionKeys.Ai);
var now = DateTime.UtcNow;
if (permission is null)
{
permission = new UserFeaturePermission
{
UserId = id,
PermissionKey = FeaturePermissionKeys.Ai,
CreatedAt = now,
};
db.UserFeaturePermissions.Add(permission);
}
permission.IsEnabled = req.Ai;
permission.UpdatedAt = now;
if (!req.Ai) user.AppMode = AppMode.Normal;
await db.SaveChangesAsync();
return Ok(new { user.Id, permissions = new { ai = permission.IsEnabled } });
}
[HttpPut("users/{id:long}/ai-quota")]
public async Task<IActionResult> UpdateAiChatQuota(
long id,
UpdateAiChatQuotaRequest req)
{
if (req.Limit is < 0 or > 1_000_000)
return BadRequest(new ApiError(
"AI_QUOTA_LIMIT_INVALID",
"AI 对话次数必须在 0 到 1000000 之间,0 表示不限次数"));
if (!AiChatQuotaService.TryParsePeriod(req.Period, out var period))
return BadRequest(new ApiError(
"AI_QUOTA_PERIOD_INVALID",
"周期仅支持 day、week 或 month"));
var user = await db.Users.FindAsync(id);
if (user is null) return NotFound();
var periodChanged = user.AiChatPeriod != period;
user.AiChatLimit = req.Limit;
user.AiChatPeriod = period;
if (periodChanged || req.ResetUsage)
{
var (windowStart, _) = AiChatQuotaService.CurrentWindow(period);
user.AiChatWindowStartedAt = windowStart;
user.AiChatUsed = 0;
}
await db.SaveChangesAsync();
var quota = AiChatQuotaService.GetStatus(user);
return Ok(new
{
user.Id,
aiChatQuota = new
{
quota.Limit,
quota.Used,
quota.Remaining,
period = AiChatQuotaService.PeriodKey(quota.Period),
quota.ResetAt,
},
});
}
[HttpGet("users/{id:long}/stats")] public async Task<IActionResult> UserStats(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); var txCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id); var aiCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && TransactionSourceRules.AiAssisted.Contains(t.Source)); var undone = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { totalTransactions = txCount, aiBooked = aiCount, aiAccuracy = aiCount == 0 ? 0 : Math.Round((1.0 - (double)undone / aiCount) * 100, 1) }); }
private static bool IsSecret(string key) =>
key.Equals("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
key.Contains("password", StringComparison.OrdinalIgnoreCase) ||
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
}
public record UpdateConfigRequest(string Value);
public record CreateConfigRequest(string Key, string Value);
public record UpsertPersonaRequest(string Key, string Name, string Description, string SampleLine, string PromptTemplate, bool IsEnabled = true);
public record UpsertAvatarRequest(string Key, string Name, string SpeechTic, string? ImageUrl, bool IsEnabled = true);
public record UpsertStickerRequest(string Key, string Label, string GroupKey, string? TriggerTags, string? ImageUrl, bool IsEnabled = true);
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
@@ -0,0 +1,103 @@
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[EnableRateLimiting("auth")]
[Route("api/auth")]
public class AuthController(AppDbContext db, JwtService jwt) : ControllerBase
{
[HttpPost("register")]
public async Task<ActionResult<AuthResponse>> Register(RegisterRequest req)
{
if (!req.AgreedToTerms)
return BadRequest(new ApiError("TERMS_NOT_ACCEPTED", "请先阅读并同意用户协议与隐私政策"));
if (req.Username.Length is < 3 or > 32) return BadRequest(new ApiError("USERNAME_INVALID", "用户名长度需在 3-32 之间"));
if (req.Password.Length < 6) return BadRequest(new ApiError("PASSWORD_TOO_SHORT", "密码至少 6 位"));
if (await db.Users.AnyAsync(u => u.Username == req.Username)) return Conflict(new ApiError("USERNAME_TAKEN", "用户名已被占用"));
var now = DateTime.UtcNow;
var defaultAiEnabled = !string.Equals(
(await db.AppConfigs.FirstOrDefaultAsync(c => c.Key == "permission.default.ai_enabled"))?.Value,
"false",
StringComparison.OrdinalIgnoreCase);
var quotaConfig = await db.AppConfigs
.Where(c => c.Key == "quota.default_ai_chat_limit" ||
c.Key == "quota.default_ai_chat_period")
.ToDictionaryAsync(c => c.Key, c => c.Value);
var defaultAiChatLimit =
quotaConfig.TryGetValue("quota.default_ai_chat_limit", out var limitValue) &&
int.TryParse(limitValue, out var parsedLimit)
? Math.Clamp(parsedLimit, 0, 1_000_000)
: 50;
var periodValue = quotaConfig.GetValueOrDefault(
"quota.default_ai_chat_period",
"day");
if (!AiChatQuotaService.TryParsePeriod(periodValue, out var defaultAiChatPeriod))
defaultAiChatPeriod = AiQuotaPeriod.Day;
var (quotaWindowStart, _) =
AiChatQuotaService.CurrentWindow(defaultAiChatPeriod);
var user = new User
{
Username = req.Username,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
AiChatLimit = defaultAiChatLimit,
AiChatPeriod = defaultAiChatPeriod,
AiChatWindowStartedAt = quotaWindowStart,
CreatedAt = now,
};
user.FeaturePermissions.Add(new UserFeaturePermission
{
PermissionKey = FeaturePermissionKeys.Ai,
IsEnabled = defaultAiEnabled,
CreatedAt = now,
UpdatedAt = now,
});
var defaultLedgerName = (await db.AppConfigs.FirstOrDefaultAsync(c => c.Key == "system.default_ledger_name"))?.Value ?? "日常账本";
user.Ledgers.Add(new Ledger { Name = defaultLedgerName, IsDefault = true, CreatedAt = DateTime.UtcNow });
db.Users.Add(user);
await db.SaveChangesAsync();
var (token, expires) = jwt.Issue(user);
return Ok(new AuthResponse(user.Id, user.Username, token, expires));
}
[HttpPost("login")]
public async Task<ActionResult<AuthResponse>> Login(LoginRequest req)
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == req.Username);
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) return Unauthorized(new ApiError("BAD_CREDENTIALS", "用户名或密码错误"));
if (user.IsBanned)
return Unauthorized(new ApiError("USER_BANNED", "账号已被封禁"));
var closureCancelled = false;
if (user.AccountClosureScheduledAt.HasValue)
{
if (user.AccountClosureScheduledAt <= DateTime.UtcNow)
return Unauthorized(new ApiError(
"ACCOUNT_CLOSED",
"账号注销等待期已结束,无法恢复"));
user.AccountClosureRequestedAt = null;
user.AccountClosureScheduledAt = null;
user.AuthVersion++;
closureCancelled = true;
}
user.LastLoginAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var (token, expires) = jwt.Issue(user);
return Ok(new AuthResponse(
user.Id,
user.Username,
token,
expires,
closureCancelled));
}
}
@@ -0,0 +1,349 @@
using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/budgets")]
public class BudgetsController(
AppDbContext db,
LedgerResolver ledgers,
BudgetRecommendationService recommendations) : ControllerBase
{
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
[HttpGet]
public async Task<ActionResult<BudgetsResponse>> Get(
[FromQuery] int year,
[FromQuery] int month,
[FromQuery] long? ledgerId = null)
{
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var period = year * 100 + month;
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var targetLedgerId = resolvedLedgerId.Value;
var rows = await db.Budgets
.Where(b => b.UserId == Uid && b.LedgerId == targetLedgerId && (b.Period == period || b.Period == 0))
.ToListAsync();
var budgets = rows
.GroupBy(b => b.CategoryId)
.Select(g => g.FirstOrDefault(b => b.Period == period) ?? g.First(b => b.Period == 0))
.ToList();
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)
.GroupBy(t => t.CategoryId)
.Select(g => new { g.Key, Amount = g.Sum(t => t.Amount) })
.ToDictionaryAsync(x => x.Key, x => x.Amount);
var totalSpent = spentByCat.Values.Sum();
var cats = await db.Categories
.Where(c => !c.IsDeleted && (c.UserId == null || c.UserId == Uid))
.ToDictionaryAsync(c => c.Id);
BudgetItemDto? total = null;
var items = new List<BudgetItemDto>();
foreach (var budget in budgets)
{
if (budget.CategoryId is null)
{
total = new BudgetItemDto(null, null, null, budget.Amount, totalSpent, budget.Period == 0);
}
else if (cats.TryGetValue(budget.CategoryId.Value, out var category))
{
items.Add(new BudgetItemDto(
category.Id,
category.Name,
category.IconKey,
budget.Amount,
spentByCat.GetValueOrDefault(category.Id, 0),
budget.Period == 0,
category.ColorKey));
}
}
return Ok(new BudgetsResponse(
year,
month,
total,
items.OrderByDescending(i => i.Amount <= 0 ? 0 : i.Spent / i.Amount).ToList()));
}
[HttpPut]
public async Task<IActionResult> Upsert(
UpsertBudgetRequest req,
[FromQuery] int year,
[FromQuery] int month,
[FromQuery(Name = "ledgerId")] long? requestedLedgerId = null)
{
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, requestedLedgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var ledgerId = resolvedLedgerId.Value;
if (req.Recurring)
{
var overrideRow = await db.Budgets.FirstOrDefaultAsync(b =>
b.UserId == Uid && b.LedgerId == ledgerId &&
b.Period == year * 100 + month && b.CategoryId == req.CategoryId);
if (overrideRow is not null) db.Budgets.Remove(overrideRow);
}
await UpsertCore(ledgerId, req.Recurring ? 0 : year * 100 + month, req.CategoryId, req.Amount);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPut("batch")]
public async Task<IActionResult> Batch(
ApplyBudgetBatchRequest req,
[FromQuery] int year,
[FromQuery] int month,
[FromQuery(Name = "ledgerId")] long? requestedLedgerId = null)
{
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
if (req.Items.Count == 0) return BadRequest(new ApiError("BUDGET_EMPTY", "没有可应用的预算"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, requestedLedgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var ledgerId = resolvedLedgerId.Value;
var targetPeriod = req.Recurring ? 0 : year * 100 + month;
if (req.Recurring)
{
var affectedCategories = req.Items.Select(i => i.CategoryId).ToHashSet();
var overrides = await db.Budgets
.Where(b => b.UserId == Uid && b.LedgerId == ledgerId &&
b.Period == year * 100 + month)
.ToListAsync();
db.Budgets.RemoveRange(overrides.Where(b => affectedCategories.Contains(b.CategoryId)));
}
foreach (var item in req.Items)
await UpsertCore(ledgerId, targetPeriod, item.CategoryId, item.Amount);
await db.SaveChangesAsync();
return NoContent();
}
[HttpGet("recommendations")]
[RequireAiPermission]
public async Task<ActionResult<BudgetRecommendationResponse>> Recommendations(
[FromQuery] int year,
[FromQuery] int month,
[FromQuery(Name = "ledgerId")] long? requestedLedgerId = null)
{
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, requestedLedgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var ledgerId = resolvedLedgerId.Value;
var (currentStart, currentEnd) = ChinaClock.MonthRangeUtc(year, month);
var historyStart = currentStart.AddMonths(-3);
var transactions = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == ledgerId &&
t.Type == TransactionType.Expense &&
t.OccurredAt >= historyStart && t.OccurredAt < currentEnd)
.Select(t => new { t.CategoryId, t.Amount, t.OccurredAt })
.ToListAsync();
var categories = await db.Categories
.Where(c => !c.IsDeleted && c.Type == TransactionType.Expense &&
(c.UserId == null || c.UserId == Uid))
.ToDictionaryAsync(c => c.Id);
var currentTransactions = transactions
.Where(t => t.OccurredAt >= currentStart && t.OccurredAt < currentEnd)
.ToList();
var currentByCategory = currentTransactions
.GroupBy(t => t.CategoryId)
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var localNow = ChinaClock.Now;
var projectionAllowed = localNow.Year == year && localNow.Month == month &&
localNow.Day >= 14 && currentTransactions.Count >= 10;
var categoryIds = transactions.Select(t => t.CategoryId).Distinct().ToList();
var suggestions = new List<BudgetRecommendationItemDto>();
foreach (var categoryId in categoryIds)
{
if (!categories.TryGetValue(categoryId, out var category)) continue;
var history = Enumerable.Range(1, 3)
.Select(offset =>
{
var start = currentStart.AddMonths(-offset);
var end = start.AddMonths(1);
return transactions
.Where(t => t.CategoryId == categoryId && t.OccurredAt >= start && t.OccurredAt < end)
.Sum(t => t.Amount);
})
.Where(value => value > 0)
.OrderBy(value => value)
.ToList();
var currentSpent = currentByCategory.GetValueOrDefault(categoryId, 0);
decimal baseline;
string confidence;
if (history.Count > 0)
{
baseline = Median(history);
confidence = history.Count >= 3 ? "high" : "medium";
if (projectionAllowed && currentSpent > 0)
{
var projection = currentSpent / localNow.Day * DateTime.DaysInMonth(year, month);
baseline = baseline * 0.8m + projection * 0.2m;
}
}
else if (projectionAllowed && currentSpent > 0)
{
baseline = currentSpent / localNow.Day * DateTime.DaysInMonth(year, month);
confidence = "low";
}
else
{
continue;
}
var p75 = history.Count == 0
? baseline
: history[(int)Math.Ceiling(history.Count * 0.75) - 1];
var target = baseline * 1.05m;
var cap = p75 * 1.2m;
target = Math.Min(target, cap);
if (currentSpent >= target) target = currentSpent * 1.05m;
target = RoundFriendly(target);
suggestions.Add(new BudgetRecommendationItemDto(
categoryId,
category.Name,
category.IconKey,
target,
currentSpent,
history.Count == 0 ? baseline : history.Average(),
confidence,
category.ColorKey));
}
var message = suggestions.Count == 0
? "历史数据不足。至少记录 1 个完整月,或本月满 14 天且达到 10 笔支出后再试。"
: "建议基于最近 3 个完整月,并确保不低于本月已发生支出。";
var currentSpentTotal = currentTransactions.Sum(t => t.Amount);
return Ok(new BudgetRecommendationResponse(
year,
month,
Math.Max(suggestions.Sum(s => s.SuggestedAmount), currentSpentTotal),
message,
suggestions.OrderByDescending(s => s.SuggestedAmount).ToList()));
}
[HttpPost("recommendations/refine")]
[RequireAiPermission]
[EnableRateLimiting("ai")]
public async Task<ActionResult<BudgetRecommendationResponse>> RefineRecommendation(
RefineBudgetRecommendationRequest request,
CancellationToken ct)
{
if (request.Year is < 2000 or > 2200 || request.Month is < 1 or > 12)
return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, request.LedgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
try
{
return Ok(await recommendations.RefineAsync(
Uid,
resolvedLedgerId.Value,
request,
ct));
}
catch (ArgumentException exception)
{
return BadRequest(new ApiError("BUDGET_DRAFT_INVALID", exception.Message));
}
catch (InvalidOperationException exception)
{
return StatusCode(503, new ApiError("BUDGET_AI_UNAVAILABLE", exception.Message));
}
catch (HttpRequestException exception)
{
return StatusCode(502, new ApiError("BUDGET_AI_FAILED", exception.Message));
}
}
private async Task UpsertCore(long ledgerId, int period, long? categoryId, decimal amount)
{
if (categoryId.HasValue && !await db.Categories.AnyAsync(c =>
c.Id == categoryId && !c.IsDeleted && c.Type == TransactionType.Expense &&
(c.UserId == null || c.UserId == Uid)))
throw new ArgumentException("预算分类无效");
var existing = await db.Budgets.FirstOrDefaultAsync(b =>
b.UserId == Uid && b.LedgerId == ledgerId &&
b.Period == period && b.CategoryId == categoryId);
if (amount <= 0)
{
if (existing != null) db.Budgets.Remove(existing);
}
else if (existing != null)
{
existing.Amount = amount;
}
else
{
db.Budgets.Add(new Budget
{
UserId = Uid,
LedgerId = ledgerId,
Period = period,
CategoryId = categoryId,
Amount = amount,
});
}
}
private static decimal Median(IReadOnlyList<decimal> values)
{
if (values.Count == 0) return 0;
var middle = values.Count / 2;
return values.Count % 2 == 0
? (values[middle - 1] + values[middle]) / 2
: values[middle];
}
private static decimal RoundFriendly(decimal value)
{
var unit = value < 100 ? 10m : value < 1000 ? 50m : 100m;
return Math.Ceiling(value / unit) * unit;
}
private async Task<long> DefaultLedgerId() =>
await db.Ledgers.Where(l => l.OwnerId == Uid && l.IsDefault).Select(l => l.Id).FirstAsync();
}
[ApiController]
[Authorize]
[Route("api/stickers")]
public class StickersController(AppDbContext db) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<StickerDto>>> List()
{
var enabled = await db.AppConfigs
.Where(config => config.Key == "feature.sticker_enabled")
.Select(config => config.Value)
.FirstOrDefaultAsync();
if (enabled?.Equals("false", StringComparison.OrdinalIgnoreCase) == true)
return Ok(new List<StickerDto>());
return Ok(await db.Stickers.Where(s => s.IsEnabled)
.Select(s => new StickerDto(s.Key, s.Label, s.GroupKey)).ToListAsync());
}
}
@@ -0,0 +1,533 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/chat")]
[RequireAiPermission]
public class ChatController(
AppDbContext db,
ILlmClient llm,
LedgerResolver ledgers,
AgentService agent,
AiChatQuotaService quotas,
ILogger<ChatController> logger) : ControllerBase
{
private long Uid => long.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ??
User.FindFirstValue("sub")!);
[HttpGet("messages")]
public async Task<ActionResult<List<ChatMessageDto>>> History(
[FromQuery] int limit = 50,
[FromQuery] long? beforeId = null)
{
var boundaryId = await CurrentBoundaryId();
var query = db.ChatMessages.Where(m =>
m.UserId == Uid &&
m.Id > boundaryId &&
m.Type != ChatMessageType.ContextBoundary);
if (beforeId.HasValue)
query = query.Where(m => m.Id < beforeId.Value);
var list = await query
.OrderByDescending(m => m.Id)
.Take(Math.Min(limit, 100))
.ToListAsync();
list.Reverse();
var transactionIds = list
.Where(m => m.TransactionId.HasValue)
.Select(m => m.TransactionId!.Value)
.ToList();
var transactions = await db.Transactions
.Include(t => t.Category)
.IgnoreQueryFilters()
.Where(t => t.UserId == Uid && transactionIds.Contains(t.Id))
.ToDictionaryAsync(t => t.Id);
return Ok(list.Select(m => ToDto(m, transactions)).ToList());
}
[HttpPost("context/clear")]
public async Task<IActionResult> ClearContext()
{
db.ChatMessages.Add(new ChatMessage
{
UserId = Uid,
Role = ChatRole.System,
Type = ChatMessageType.ContextBoundary,
Content = "新会话",
CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("messages")]
[EnableRateLimiting("ai")]
public async Task<ActionResult<SendChatResponse>> Send(
SendChatRequest req,
CancellationToken ct)
{
return await SendWithAgent(req, ct);
}
[HttpPost("messages/stream")]
[EnableRateLimiting("ai")]
public async Task StreamSend(SendChatRequest req, CancellationToken ct)
{
await StreamWithAgent(req, ct);
}
private async Task<ActionResult<SendChatResponse>> SendWithAgent(
SendChatRequest req,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Content))
return BadRequest(new ApiError("CONTENT_EMPTY", "内容不能为空"));
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId, ct);
if (!ledgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
if (req.Type == "sticker" && !await FeatureEnabled("feature.sticker_enabled", ct))
return StatusCode(403, new ApiError("FEATURE_DISABLED", "表情包功能已由后台关闭"));
var allowAutoBook = await FeatureEnabled("feature.ai_auto_book", ct);
var quota = await quotas.TryConsumeAsync(Uid, ct);
if (!quota.Allowed)
return StatusCode(
StatusCodes.Status429TooManyRequests,
QuotaExceededPayload(quota));
var content = req.Content.Trim();
var now = DateTime.UtcNow;
var userMessage = new ChatMessage
{
UserId = Uid,
Role = ChatRole.User,
Type = req.Type == "sticker"
? ChatMessageType.Sticker
: ChatMessageType.Text,
Content = content,
CreatedAt = now,
};
db.ChatMessages.Add(userMessage);
await db.SaveChangesAsync(ct);
var (_, _, tic, personaPrompt) = await LoadUserConfig(ct);
var replies = new List<ChatMessage>();
var transactions = new List<Transaction>();
if (userMessage.Type == ChatMessageType.Sticker)
{
var (text, sticker) = await StickerLlmAsync(content, tic, ct);
replies.Add(AssistantText(text, now.AddMilliseconds(1)));
if (sticker is not null)
{
replies.Add(new ChatMessage
{
UserId = Uid,
Role = ChatRole.Assistant,
Type = ChatMessageType.Sticker,
Content = sticker,
CreatedAt = now.AddMilliseconds(2),
});
}
}
else
{
try
{
var result = await agent.RunAsync(
Uid,
ledgerId.Value,
allowAutoBook,
content,
userMessage,
MakeSys(personaPrompt, tic, ""),
await LoadConversationContext(userMessage.Id, ct),
ct: ct);
transactions.AddRange(result.Transactions);
var reply = string.IsNullOrWhiteSpace(result.Reply)
? AgentFallback(transactions, tic)
: result.Reply;
replies.Add(AssistantText(reply, now.AddMilliseconds(1)));
}
catch (Exception ex)
{
logger.LogWarning(ex, "Agent chat failed for user {UserId}", Uid);
transactions.AddRange(await TransactionsForMessage(
userMessage.Id,
ct));
var reply = transactions.Count > 0
? AgentFallback(transactions, tic)
: "AI 暂时无法完成这次操作,没有写入任何账单,请稍后重试";
replies.Add(AssistantText(reply, now.AddMilliseconds(1)));
}
for (var index = 0; index < transactions.Count; index++)
{
replies.Add(BillCard(
transactions[index].Id,
now.AddMilliseconds(index + 2)));
}
}
db.ChatMessages.AddRange(replies);
await db.SaveChangesAsync(ct);
var transactionMap = transactions.ToDictionary(t => t.Id);
return Ok(new SendChatResponse(
new[] { userMessage }
.Concat(replies)
.Select(message => ToDto(message, transactionMap))
.ToList()));
}
private async Task StreamWithAgent(
SendChatRequest req,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Content))
{
HttpContext.Response.StatusCode = 400;
return;
}
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId, ct);
if (!ledgerId.HasValue)
{
HttpContext.Response.StatusCode = 400;
await HttpContext.Response.WriteAsJsonAsync(
new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"),
ct);
return;
}
var quota = await quotas.TryConsumeAsync(Uid, ct);
if (!quota.Allowed)
{
HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await HttpContext.Response.WriteAsJsonAsync(
QuotaExceededPayload(quota),
ct);
return;
}
var allowAutoBook = await FeatureEnabled("feature.ai_auto_book", ct);
var content = req.Content.Trim();
var now = DateTime.UtcNow;
var userMessage = new ChatMessage
{
UserId = Uid,
Role = ChatRole.User,
Type = ChatMessageType.Text,
Content = content,
CreatedAt = now,
};
db.ChatMessages.Add(userMessage);
await db.SaveChangesAsync(ct);
HttpContext.Response.ContentType = "text/event-stream; charset=utf-8";
HttpContext.Response.Headers["Cache-Control"] = "no-cache, no-transform";
HttpContext.Response.Headers["Connection"] = "keep-alive";
HttpContext.Response.Headers["X-Accel-Buffering"] = "no";
await HttpContext.Response.Body.FlushAsync(ct);
await WriteSsePayload(new { status = "thinking" }, ct);
var (_, _, tic, personaPrompt) = await LoadUserConfig(ct);
var fullReply = new StringBuilder();
var transactions = new List<Transaction>();
var responseDisconnected = false;
try
{
var result = await agent.RunAsync(
Uid,
ledgerId.Value,
allowAutoBook,
content,
userMessage,
MakeSys(personaPrompt, tic, ""),
await LoadConversationContext(userMessage.Id, ct),
async (token, tokenCt) =>
{
fullReply.Append(token);
await WriteSse("t", token, tokenCt);
},
(tool, toolCt) => WriteSsePayload(
new { status = "tool_running", tool },
toolCt),
ct);
transactions.AddRange(result.Transactions);
if (fullReply.Length == 0 && !string.IsNullOrWhiteSpace(result.Reply))
fullReply.Append(result.Reply);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
responseDisconnected = true;
fullReply.Clear();
transactions.AddRange(await TransactionsForMessage(
userMessage.Id,
CancellationToken.None));
if (transactions.Count == 0) return;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Streaming agent chat failed for user {UserId}", Uid);
transactions.AddRange(await TransactionsForMessage(
userMessage.Id,
CancellationToken.None));
}
if (responseDisconnected || fullReply.Length == 0)
{
var fallback = transactions.Count > 0
? AgentFallback(transactions, tic)
: "AI 暂时无法完成这次操作,没有写入任何账单,请稍后重试";
fullReply.Clear();
fullReply.Append(fallback);
if (!responseDisconnected) await WriteSse("t", fallback, ct);
}
var replies = new List<ChatMessage>
{
AssistantText(fullReply.ToString(), now.AddMilliseconds(1)),
};
for (var index = 0; index < transactions.Count; index++)
{
replies.Add(BillCard(
transactions[index].Id,
now.AddMilliseconds(index + 2)));
}
db.ChatMessages.AddRange(replies);
await db.SaveChangesAsync(
responseDisconnected ? CancellationToken.None : ct);
if (responseDisconnected) return;
var transactionMap = transactions.ToDictionary(t => t.Id);
await WriteSsePayload(
new
{
done = true,
messages = replies
.Select(message => ToDto(message, transactionMap))
.ToList(),
},
ct);
}
private static object QuotaExceededPayload(AiChatQuotaStatus quota) => new
{
code = "AI_CHAT_QUOTA_EXCEEDED",
message = $"本周期 AI 对话次数已用完,将于 {ChinaClock.ToLocal(quota.ResetAt):MM月dd日 HH:mm} 重置",
quota = new
{
quota.Limit,
quota.Used,
quota.Remaining,
period = AiChatQuotaService.PeriodKey(quota.Period),
quota.ResetAt,
},
};
private async Task<List<Transaction>> TransactionsForMessage(
long messageId,
CancellationToken ct) =>
await db.Transactions
.Include(transaction => transaction.Category)
.Where(transaction =>
transaction.UserId == Uid &&
transaction.SourceChatMessageId == messageId)
.OrderBy(transaction => transaction.Id)
.ToListAsync(ct);
private static string AgentFallback(
IReadOnlyList<Transaction> transactions,
string tic)
{
if (transactions.Count == 0)
return "这句话里的金额或收支方向还不够明确,可以再说具体一点";
if (transactions.Count == 1)
{
var transaction = transactions[0];
var type = transaction.Type == TransactionType.Income
? "收入"
: "支出";
return $"已记录{type}{transaction.Category.Name} ¥{transaction.Amount:F2}{tic}";
}
var income = transactions
.Where(t => t.Type == TransactionType.Income)
.Sum(t => t.Amount);
var expense = transactions
.Where(t => t.Type == TransactionType.Expense)
.Sum(t => t.Amount);
return $"已记录 {transactions.Count} 笔,其中收入 ¥{income:F2}、支出 ¥{expense:F2}{tic}";
}
private ChatMessage AssistantText(string content, DateTime createdAt) => new()
{
UserId = Uid,
Role = ChatRole.Assistant,
Type = ChatMessageType.Text,
Content = content,
CreatedAt = createdAt,
};
private ChatMessage BillCard(long transactionId, DateTime createdAt) => new()
{
UserId = Uid,
Role = ChatRole.Assistant,
Type = ChatMessageType.BillCard,
Content = "",
TransactionId = transactionId,
CreatedAt = createdAt,
};
private async Task<long> CurrentBoundaryId(CancellationToken ct = default) =>
await db.ChatMessages
.Where(m =>
m.UserId == Uid &&
m.Type == ChatMessageType.ContextBoundary)
.MaxAsync(m => (long?)m.Id, ct) ?? 0;
private async Task<string> LoadConversationContext(
long beforeMessageId,
CancellationToken ct)
{
var boundaryId = await CurrentBoundaryId(ct);
var messages = await db.ChatMessages
.Where(m =>
m.UserId == Uid &&
m.Id > boundaryId &&
m.Id < beforeMessageId &&
m.Type == ChatMessageType.Text &&
m.Role != ChatRole.System)
.OrderByDescending(m => m.Id)
.Take(12)
.Select(m => new { m.Role, m.Content })
.ToListAsync(ct);
if (messages.Count == 0) return string.Empty;
messages.Reverse();
var lines = messages.Select(m =>
(m.Role == ChatRole.User ? "用户:" : "助手:") +
(m.Content.Length > 240 ? m.Content[..240] : m.Content));
return "\n当前会话最近消息(仅用于保持上下文,不要重复):\n" +
string.Join("\n", lines);
}
private async Task WriteSse(
string key,
string value,
CancellationToken ct)
{
var json = JsonSerializer.Serialize(
new Dictionary<string, string> { [key] = value },
new JsonSerializerOptions(JsonSerializerDefaults.Web));
await HttpContext.Response.WriteAsync($"data: {json}\n\n", ct);
await HttpContext.Response.Body.FlushAsync(ct);
}
private async Task WriteSsePayload(object payload, CancellationToken ct)
{
var json = JsonSerializer.Serialize(
payload,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
await HttpContext.Response.WriteAsync($"data: {json}\n\n", ct);
await HttpContext.Response.Body.FlushAsync(ct);
}
private async Task<bool> FeatureEnabled(string key, CancellationToken ct)
{
var value = await db.AppConfigs
.Where(config => config.Key == key)
.Select(config => config.Value)
.FirstOrDefaultAsync(ct);
return value is null || !value.Equals("false", StringComparison.OrdinalIgnoreCase);
}
private async Task<(string PersonaKey, string AvatarKey, string Tic, string? PersonaPrompt)>
LoadUserConfig(CancellationToken ct)
{
var config = await db.AiCompanionSettings
.FirstOrDefaultAsync(s => s.UserId == Uid, ct);
var personaKey = config?.PersonaKey ?? "sassy_cat";
var avatarKey = config?.AvatarKey ?? "cat";
var tic = await db.AiAvatars
.Where(a => a.Key == avatarKey)
.Select(a => a.SpeechTic)
.FirstOrDefaultAsync(ct) ?? "";
var personaPrompt = await db.AiPersonas
.Where(p => p.Key == personaKey && p.IsEnabled)
.Select(p => p.PromptTemplate)
.FirstOrDefaultAsync(ct);
return (personaKey, avatarKey, tic, personaPrompt);
}
private async Task<(string Text, string? Sticker)> StickerLlmAsync(
string stickerKey,
string tic,
CancellationToken ct)
{
var prompt = stickerKey == "salary"
? "用户发了发工资的表情。问他发了多少。口癖: " + tic
: "用户发了表情包。简短回复1句话。口癖: " + tic;
var reply = llm.IsEnabled
? await llm.TryGenerateReplyAsync(prompt, stickerKey, ct)
: null;
return (
reply ?? (stickerKey == "salary"
? "发工资了?多少" + tic
: "收到" + tic),
stickerKey == "salary" ? null : "happy");
}
private static string MakeSys(
string? personaPrompt,
string tic,
string context)
{
var basePrompt = !string.IsNullOrWhiteSpace(personaPrompt)
? personaPrompt.Replace("{tic}", tic).Trim()
: "你是记之 AI 助手。回复简短风趣。口癖: " + tic;
return basePrompt + "\n" + context;
}
private static ChatMessageDto ToDto(
ChatMessage message,
IReadOnlyDictionary<long, Transaction> transactions)
{
TransactionDto? transaction = null;
if (message.TransactionId.HasValue &&
transactions.TryGetValue(
message.TransactionId.Value,
out var value))
{
transaction = TransactionsController.ToDto(
value,
value.Category);
}
return new ChatMessageDto(
message.Id,
message.Role == ChatRole.User ? "user" : "assistant",
message.Type switch
{
ChatMessageType.Sticker => "sticker",
ChatMessageType.BillCard => "bill_card",
_ => "text",
},
message.Content,
transaction,
message.CreatedAt);
}
}
@@ -0,0 +1,269 @@
using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/ledgers")]
public class LedgersController(AppDbContext db) : ControllerBase
{
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
[HttpGet]
public async Task<IActionResult> List() =>
Ok(await db.Ledgers.Where(l => l.OwnerId == Uid)
.OrderByDescending(l => l.IsDefault).ThenBy(l => l.CreatedAt)
.Select(l => new { l.Id, l.Name, l.IconKey, l.IsDefault, TxCount = db.Transactions.IgnoreQueryFilters().Count(t => t.LedgerId == l.Id && t.UserId == Uid) })
.ToListAsync());
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateLedgerReq req)
{
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 12)
return BadRequest(new ApiError("NAME_INVALID", "账本名 1-12 个字"));
var maxCount = int.TryParse((await db.AppConfigs.FirstOrDefaultAsync(c => c.Key == "system.max_ledgers_per_user"))?.Value, out var v) ? v : 10;
if (await db.Ledgers.CountAsync(l => l.OwnerId == Uid) >= maxCount)
return BadRequest(new ApiError("LIMIT_REACHED", $"每人最多 {maxCount} 个账本"));
var ledger = new Ledger { OwnerId = Uid, Name = req.Name.Trim(), IconKey = req.IconKey ?? "wallet" };
db.Ledgers.Add(ledger);
await db.SaveChangesAsync();
return Ok(new { ledger.Id, ledger.Name, ledger.IconKey, ledger.IsDefault, TxCount = 0 });
}
[HttpPut("{id:long}/default")]
public async Task<IActionResult> SetDefault(long id)
{
var target = await db.Ledgers.FirstOrDefaultAsync(l => l.Id == id && l.OwnerId == Uid);
if (target is null) return NotFound(new ApiError("LEDGER_NOT_FOUND", "账本不存在"));
await db.Ledgers.Where(l => l.OwnerId == Uid && l.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(l => l.IsDefault, false));
target.IsDefault = true;
await db.SaveChangesAsync();
return NoContent();
}
[HttpPut("{id:long}")]
public async Task<IActionResult> Rename(long id, UpdateLedgerReq req)
{
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Trim().Length > 12)
return BadRequest(new ApiError("NAME_INVALID", "账本名 1-12 个字"));
var ledger = await db.Ledgers.FirstOrDefaultAsync(l => l.Id == id && l.OwnerId == Uid);
if (ledger is null) return NotFound(new ApiError("LEDGER_NOT_FOUND", "账本不存在"));
ledger.Name = req.Name.Trim();
if (!string.IsNullOrWhiteSpace(req.IconKey)) ledger.IconKey = req.IconKey.Trim();
await db.SaveChangesAsync();
return Ok(new LedgerDto(ledger.Id, ledger.Name, ledger.IconKey, ledger.IsDefault));
}
[HttpPut("reorder")]
public async Task<IActionResult> Reorder(ReorderCategoriesRequest req)
{
if (req.Type is not ("income" or "expense"))
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
if (req.CategoryIds.Count != req.CategoryIds.Distinct().Count())
return BadRequest(new ApiError("ORDER_INVALID", "分类排序中存在重复项"));
var type = req.Type == "income"
? TransactionType.Income
: TransactionType.Expense;
var categories = await db.Categories
.Where(c => c.UserId == Uid && c.Type == type && !c.IsDeleted)
.ToListAsync();
if (categories.Count != req.CategoryIds.Count ||
categories.Select(c => c.Id).ToHashSet()
.SetEquals(req.CategoryIds) is false)
return BadRequest(new ApiError("ORDER_INVALID", "请提交完整的自定义分类顺序"));
var byId = categories.ToDictionary(c => c.Id);
for (var index = 0; index < req.CategoryIds.Count; index++)
byId[req.CategoryIds[index]].SortOrder = 1000 + index;
await db.SaveChangesAsync();
return NoContent();
}
[HttpDelete("{id:long}")]
public async Task<IActionResult> Delete(long id)
{
var ledger = await db.Ledgers.FirstOrDefaultAsync(l => l.Id == id && l.OwnerId == Uid);
if (ledger is null) return NotFound(new ApiError("LEDGER_NOT_FOUND", "账本不存在"));
if (ledger.IsDefault)
return Conflict(new ApiError("DEFAULT_LEDGER", "默认账本不能删除,请先切换到其他账本"));
var hasTransactions = await db.Transactions.IgnoreQueryFilters()
.AnyAsync(t => t.UserId == Uid && t.LedgerId == id);
var hasBudgets = await db.Budgets.AnyAsync(b => b.UserId == Uid && b.LedgerId == id);
if (hasTransactions || hasBudgets)
return Conflict(new ApiError("LEDGER_NOT_EMPTY", "仅可删除没有账单和预算的账本"));
db.Ledgers.Remove(ledger);
await db.SaveChangesAsync();
return NoContent();
}
}
public record CreateLedgerReq(string Name, string? IconKey);
public record UpdateLedgerReq(string Name, string? IconKey);
[ApiController]
[Authorize]
[Route("api/categories")]
public class CategoriesController(AppDbContext db) : ControllerBase
{
internal static readonly HashSet<string> AllowedIcons =
[
"food", "cup", "cart", "house", "rent", "metro", "car", "plane",
"phone", "wifi", "shirt", "beauty", "pill", "sport", "book", "baby",
"pet", "game", "gift", "wallet", "money", "briefcase", "chart",
"interest", "refund", "card", "insurance", "tax", "receipt",
"camera", "target", "sparkle", "tag",
];
internal static readonly HashSet<string> AllowedColors =
[
"mint", "teal", "aqua", "cyan", "sky", "blue",
"navy", "indigo", "violet", "plum", "orchid", "rose",
"coral", "red", "orange", "amber", "peach", "sand",
"lime", "olive", "forest", "slate", "cocoa", "graphite",
];
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
internal static string NormalizeIcon(string? iconKey) => iconKey?.Trim() switch
{
"shopping" => "cart",
"transport" => "metro",
"home" => "house",
"salary" => "money",
{ Length: > 0 } value => value,
_ => "tag",
};
[HttpGet]
public async Task<ActionResult<List<CategoryDto>>> List([FromQuery] string type = "expense")
{
if (type is not ("income" or "expense"))
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
var t = type == "income" ? TransactionType.Income : TransactionType.Expense;
var list = await db.Categories
.Where(c => !c.IsDeleted && c.Type == t && (c.UserId == null || c.UserId == Uid))
.OrderBy(c => c.SortOrder)
.Select(c => new CategoryDto(
c.Id, c.Name, c.IconKey, type,
c.SortOrder, c.UserId != null, c.ColorKey))
.ToListAsync();
return Ok(list);
}
[HttpPost]
public async Task<ActionResult<CategoryDto>> Create(CreateCategoryRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 8)
return BadRequest(new ApiError("NAME_INVALID", "分类名 1-8 个字"));
if (req.Type is not ("income" or "expense"))
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
var t = req.Type == "income" ? TransactionType.Income : TransactionType.Expense;
var iconKey = NormalizeIcon(req.IconKey);
if (!AllowedIcons.Contains(iconKey))
return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在"));
var colorKey = string.IsNullOrWhiteSpace(req.ColorKey)
? "mint"
: req.ColorKey.Trim();
if (!AllowedColors.Contains(colorKey))
return BadRequest(new ApiError("COLOR_INVALID", "分类颜色不存在"));
if (await db.Categories.AnyAsync(c => !c.IsDeleted && c.Type == t && c.Name == req.Name && (c.UserId == null || c.UserId == Uid)))
return Conflict(new ApiError("NAME_TAKEN", "分类已存在"));
var maxSort = await db.Categories
.Where(c => c.Type == t && (c.UserId == null || c.UserId == Uid))
.MaxAsync(c => (int?)c.SortOrder) ?? 0;
var cat = new Category
{
UserId = Uid,
Type = t,
Name = req.Name.Trim(),
IconKey = iconKey,
ColorKey = colorKey,
SortOrder = Math.Max(999, maxSort) + 1,
};
db.Categories.Add(cat);
await db.SaveChangesAsync();
return Ok(new CategoryDto(
cat.Id, cat.Name, cat.IconKey, req.Type,
cat.SortOrder, true, cat.ColorKey));
}
[HttpPut("{id:long}")]
public async Task<ActionResult<CategoryDto>> Update(long id, UpdateCategoryRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Trim().Length > 8)
return BadRequest(new ApiError("NAME_INVALID", "分类名 1-8 个字"));
var category = await db.Categories.FirstOrDefaultAsync(c =>
c.Id == id && c.UserId == Uid && !c.IsDeleted);
if (category is null)
return NotFound(new ApiError("CATEGORY_NOT_FOUND", "分类不存在或非自定义分类"));
var name = req.Name.Trim();
if (await db.Categories.AnyAsync(c => c.Id != id && !c.IsDeleted &&
c.Type == category.Type && c.Name == name &&
(c.UserId == null || c.UserId == Uid)))
return Conflict(new ApiError("NAME_TAKEN", "分类已存在"));
var iconKey = NormalizeIcon(req.IconKey);
if (!AllowedIcons.Contains(iconKey))
return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在"));
var colorKey = req.ColorKey is null
? category.ColorKey
: req.ColorKey.Trim();
if (!AllowedColors.Contains(colorKey))
return BadRequest(new ApiError("COLOR_INVALID", "分类颜色不存在"));
category.Name = name;
category.IconKey = iconKey;
category.ColorKey = colorKey;
category.SortOrder = Math.Max(1000, req.SortOrder);
await db.SaveChangesAsync();
return Ok(new CategoryDto(
category.Id, category.Name, category.IconKey,
category.Type == TransactionType.Income ? "income" : "expense",
category.SortOrder, true, category.ColorKey));
}
[HttpPut("reorder")]
public async Task<IActionResult> Reorder(ReorderCategoriesRequest req)
{
if (req.Type is not ("income" or "expense"))
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
if (req.CategoryIds.Count != req.CategoryIds.Distinct().Count())
return BadRequest(new ApiError("ORDER_INVALID", "分类排序中存在重复项"));
var type = req.Type == "income"
? TransactionType.Income
: TransactionType.Expense;
var categories = await db.Categories
.Where(c => c.UserId == Uid && c.Type == type && !c.IsDeleted)
.ToListAsync();
if (categories.Count != req.CategoryIds.Count ||
categories.Select(c => c.Id).ToHashSet()
.SetEquals(req.CategoryIds) is false)
return BadRequest(new ApiError("ORDER_INVALID", "请提交完整的自定义分类顺序"));
var byId = categories.ToDictionary(c => c.Id);
for (var index = 0; index < req.CategoryIds.Count; index++)
byId[req.CategoryIds[index]].SortOrder = 1000 + index;
await db.SaveChangesAsync();
return NoContent();
}
[HttpDelete("{id:long}")]
public async Task<IActionResult> Delete(long id)
{
var cat = await db.Categories.FirstOrDefaultAsync(c => c.Id == id && c.UserId == Uid);
if (cat is null) return NotFound(new ApiError("CATEGORY_NOT_FOUND", "分类不存在或非自定义分类"));
cat.IsDeleted = true;
await db.SaveChangesAsync();
return NoContent();
}
}
public record ReorderCategoriesRequest(string Type, List<long> CategoryIds);
@@ -0,0 +1,239 @@
using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
/// <summary>
/// OCR/语音/图片辅助解析。客户端确认后再调用交易接口正式入账。
/// 图片模式由后端多模态模型提取一笔或多笔账单草稿。
/// </summary>
[ApiController]
[Authorize]
[Route("api/parse")]
[RequireAiPermission]
public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent) : ControllerBase
{
private long Uid => long.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ??
User.FindFirstValue("sub")!);
[HttpPost]
[EnableRateLimiting("ai")]
public async Task<ActionResult<OcrParseResponse>> Parse(OcrParseRequest req)
{
return await ParseWithAgent(req, HttpContext.RequestAborted);
}
private async Task<ActionResult<OcrParseResponse>> ParseWithAgent(
OcrParseRequest req,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Text))
return BadRequest(new ApiError("TEXT_EMPTY", "内容不能为空"));
var featureKey = req.Source == "voice"
? "feature.voice_enabled"
: "feature.ocr_enabled";
if (!await FeatureEnabled(featureKey))
return StatusCode(403, new ApiError("FEATURE_DISABLED", "该解析功能已由后台关闭"));
IReadOnlyList<ParsedBill> drafts;
try
{
drafts = await agent.ParseDraftsAsync(Uid, req.Text.Trim(), ct);
}
catch (InvalidOperationException ex)
{
return StatusCode(
503,
new ApiError("AGENT_PARSE_UNAVAILABLE", ex.Message));
}
catch (Exception)
{
return StatusCode(
502,
new ApiError(
"AGENT_PARSE_FAILED",
"AI 暂时无法解析,没有生成账单草稿"));
}
if (drafts.Count == 0)
{
var fallback = await db.Categories.FirstAsync(
c => c.Name == "其他" &&
c.Type == TransactionType.Expense &&
c.UserId == null,
ct);
return Ok(new OcrParseResponse(
false,
fallback.Id,
fallback.Name,
fallback.IconKey,
0,
null,
req.Text,
"expense"));
}
var bill = drafts[0];
var category = await db.Categories.FirstAsync(
c => c.Id == bill.CategoryId && c.Type == bill.Type,
ct);
return Ok(new OcrParseResponse(
true,
category.Id,
category.Name,
category.IconKey,
bill.Amount,
bill.PaymentMethod,
bill.Note,
bill.Type == TransactionType.Income ? "income" : "expense"));
}
/// <summary>上传截屏或小票,提取其中全部独立交易。</summary>
[HttpPost("image")]
[EnableRateLimiting("upload")]
[RequestSizeLimit(10 * 1024 * 1024)]
[RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
public async Task<ActionResult<ImageParseResponse>> ParseImage(
IFormFile file,
[FromQuery] string source = "image",
CancellationToken ct = default)
{
var featureKey = source == "screenshot"
? "feature.screenshot_bookkeeping_enabled"
: "feature.ocr_enabled";
if (!await FeatureEnabled(featureKey))
return StatusCode(403, new ApiError("FEATURE_DISABLED", "该图片识别功能已由后台关闭"));
if (file is null || file.Length == 0)
return BadRequest(new ApiError("FILE_EMPTY", "请上传图片"));
if (!file.ContentType.StartsWith("image/"))
return BadRequest(new ApiError("NOT_IMAGE", "仅支持图片文件"));
if (file.Length > 10 * 1024 * 1024)
return BadRequest(new ApiError("FILE_TOO_LARGE", "图片不能超过 10MB"));
if (!llm.IsEnabled)
{
return StatusCode(
503,
new ApiError(
"LLM_NOT_CONFIGURED",
"AI 图片解析未配置,请在管理后台设置 LLM API Key"));
}
using var stream = new MemoryStream();
await file.CopyToAsync(stream, ct);
IReadOnlyList<ImageParseResult>? results;
try
{
results = await llm.AnalyzeImageAsync(
stream.ToArray(),
file.ContentType,
ct);
}
catch (InvalidOperationException ex)
{
return StatusCode(502, new ApiError("LLM_IMAGE_ERROR", ex.Message));
}
if (results is null)
return StatusCode(500, new ApiError("LLM_ERROR", "AI 解析失败,请稍后重试"));
var categories = await db.Categories
.Where(c => !c.IsDeleted && (c.UserId == null || c.UserId == Uid))
.ToListAsync(ct);
if (results.Count == 0)
{
return Ok(new ImageParseResponse(
false,
0,
"待确认",
"tag",
0,
null,
"",
"unknown",
[],
null));
}
var items = new List<ImageParseItemResponse>();
foreach (var result in results.Take(20))
{
var normalizedType = result.Type.Trim().ToLowerInvariant();
if (normalizedType is not ("income" or "expense"))
{
items.Add(new ImageParseItemResponse(
false, 0, "待确认", "tag", "unknown",
result.Amount, result.PaymentMethod, result.Note,
result.OccurredAt));
continue;
}
var type = normalizedType == "income"
? TransactionType.Income
: TransactionType.Expense;
var category = FindCategory(categories, type, result.CategoryName);
items.Add(new ImageParseItemResponse(
true,
category.Id,
category.Name,
category.IconKey,
normalizedType,
result.Amount,
result.PaymentMethod,
result.Note,
result.OccurredAt));
}
var first = items[0];
return Ok(new ImageParseResponse(
true,
first.CategoryId,
first.CategoryName,
first.CategoryIcon,
first.Amount,
first.PaymentMethod,
first.Note,
first.Type,
items,
first.OccurredAt));
}
private async Task<bool> FeatureEnabled(string key)
{
var value = await db.AppConfigs
.Where(config => config.Key == key)
.Select(config => config.Value)
.FirstOrDefaultAsync();
return value is null || !value.Equals("false", StringComparison.OrdinalIgnoreCase);
}
private Category FindCategory(
List<Category> categories,
TransactionType type,
string requestedName)
{
if (type == TransactionType.Income &&
requestedName is "退款" or "返现" or "退回")
{
requestedName = "报销";
}
return categories
.Where(c => c.Type == type && c.Name == requestedName)
.OrderByDescending(c => c.UserId == Uid)
.FirstOrDefault()
?? categories.First(
c => c.Type == type &&
c.Name == "其他" &&
c.UserId == null);
}
}
@@ -0,0 +1,49 @@
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
/// <summary>公开配置:前端拉取品牌信息、可用形象/性格列表(无需管理员权限)</summary>
[ApiController]
[Route("api/public")]
public class PublicConfigController(AppDbContext db) : ControllerBase
{
/// <summary>品牌配置(App 名称、Slogan、Logo</summary>
[HttpGet("brand")]
public async Task<IActionResult> Brand()
{
var configs = await db.AppConfigs
.Where(c => c.Key.StartsWith("brand.") || c.Key.StartsWith("feature."))
.ToDictionaryAsync(c => c.Key, c => c.Value);
bool Enabled(string key) =>
!configs.TryGetValue(key, out var value) ||
!value.Equals("false", StringComparison.OrdinalIgnoreCase);
return Ok(new
{
appName = configs.GetValueOrDefault("brand.app_name", "记之"),
slogan = configs.GetValueOrDefault("brand.slogan", ""),
logoUrl = configs.GetValueOrDefault("brand.logo_url", ""),
features = new
{
voice = Enabled("feature.voice_enabled"),
image = Enabled("feature.ocr_enabled"),
screenshot = Enabled("feature.screenshot_bookkeeping_enabled"),
stickers = Enabled("feature.sticker_enabled"),
aiAutoBook = Enabled("feature.ai_auto_book"),
},
});
}
/// <summary>可用的 AI 形象列表(按 Key 排序)</summary>
[HttpGet("avatars")]
public async Task<IActionResult> Avatars() =>
Ok(await db.AiAvatars.Where(a => a.IsEnabled).OrderBy(a => a.Key)
.Select(a => new { a.Key, a.DefaultName, a.SpeechTic, a.ImageUrl }).ToListAsync());
/// <summary>可用的 AI 性格列表(按 Key 排序)</summary>
[HttpGet("personas")]
public async Task<IActionResult> Personas() =>
Ok(await db.AiPersonas.Where(p => p.IsEnabled).OrderBy(p => p.Key)
.Select(p => new { p.Key, p.Name, p.Description, p.SampleLine }).ToListAsync());
}
@@ -0,0 +1,284 @@
using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/reports")]
public class ReportsController(
AppDbContext db,
ReplyService reply,
LedgerResolver ledgers,
AiPermissionService aiPermissions) : ControllerBase
{
private long Uid => long.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ??
User.FindFirstValue("sub")!);
[HttpGet("weekly")]
public async Task<ActionResult<PeriodReportDto>> Weekly(
[FromQuery] DateTime? date = null,
[FromQuery] long? ledgerId = null)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError(
"LEDGER_NOT_FOUND",
"账本不存在或无权访问"));
var selected = DateTime.SpecifyKind(
(date ?? ChinaClock.Now).Date,
DateTimeKind.Unspecified);
var (start, end) = ChinaClock.WeekRangeUtc(selected);
var localStart = ChinaClock.ToLocal(start).Date;
var localEnd = ChinaClock.ToLocal(end).Date.AddDays(-1);
return Ok(await BuildAsync(
resolvedLedgerId.Value,
start,
end,
"weekly",
$"{localStart:MM月dd日} - {localEnd:MM月dd日}",
groupByMonth: false));
}
[HttpGet("monthly")]
public async Task<ActionResult<MonthlyReportDto>> Monthly(
[FromQuery] int year,
[FromQuery] int month,
[FromQuery] long? ledgerId = null)
{
if (month is < 1 or > 12)
return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError(
"LEDGER_NOT_FOUND",
"账本不存在或无权访问"));
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var report = await BuildAsync(
resolvedLedgerId.Value,
start,
end,
"monthly",
$"{year}年{month}月",
groupByMonth: false);
return Ok(new MonthlyReportDto(
year,
month,
report.Income,
report.Expense,
report.Balance,
report.Count,
report.AiRatio,
report.PeakLabel,
report.PeakAmount,
report.PeakNote,
report.TopCategory,
report.TopCategoryAmount,
report.TopCategoryPercent,
report.CategoryRanking,
report.Commentary));
}
[HttpGet("yearly")]
public async Task<ActionResult<PeriodReportDto>> Yearly(
[FromQuery] int year,
[FromQuery] long? ledgerId = null)
{
if (year is < 2000 or > 2200)
return BadRequest(new ApiError("YEAR_INVALID", "年份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError(
"LEDGER_NOT_FOUND",
"账本不存在或无权访问"));
var (start, end) = ChinaClock.YearRangeUtc(year);
return Ok(await BuildAsync(
resolvedLedgerId.Value,
start,
end,
"yearly",
$"{year}年",
groupByMonth: true));
}
private async Task<PeriodReportDto> BuildAsync(
long ledgerId,
DateTime start,
DateTime end,
string periodType,
string periodLabel,
bool groupByMonth)
{
var list = await db.Transactions
.Include(transaction => transaction.Category)
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == ledgerId &&
transaction.OccurredAt >= start &&
transaction.OccurredAt < end)
.ToListAsync();
var income = list
.Where(transaction => transaction.Type == TransactionType.Income)
.Sum(transaction => transaction.Amount);
var expenses = list
.Where(transaction => transaction.Type == TransactionType.Expense)
.ToList();
var expense = expenses.Sum(transaction => transaction.Amount);
var aiCount = list.Count(
transaction => transaction.Source.IsAiAssisted());
var aiRatio = list.Count == 0
? 0
: (double)aiCount / list.Count * 100;
var peak = expenses
.GroupBy(transaction =>
{
var local = ChinaClock.ToLocal(transaction.OccurredAt);
return groupByMonth
? new DateTime(local.Year, local.Month, 1)
: local.Date;
})
.OrderByDescending(group => group.Sum(item => item.Amount))
.FirstOrDefault();
var categoryRanking = expenses
.GroupBy(transaction => new
{
transaction.Category.Name,
transaction.Category.IconKey,
transaction.Category.ColorKey,
})
.Select(group => new CategoryRankDto(
group.Key.Name,
group.Key.IconKey,
group.Sum(item => item.Amount),
expense == 0
? 0
: (double)(group.Sum(item => item.Amount) / expense * 100),
group.Key.ColorKey))
.OrderByDescending(item => item.Amount)
.ThenBy(item => item.Name)
.Take(5)
.ToList();
var topCategory = categoryRanking.FirstOrDefault();
var commentary = await aiPermissions.IsEnabledAsync(Uid, HttpContext.RequestAborted)
? await reply.PeriodRoastAsync(
Uid,
periodType switch
{
"weekly" => "这周",
"yearly" => "这一年",
_ => "这个月",
},
income,
expense,
topCategory?.Name ?? "无",
topCategory?.Amount ?? 0)
: string.Empty;
var localStart = ChinaClock.ToLocal(start).Date;
var localEnd = ChinaClock.ToLocal(end).Date;
return new PeriodReportDto(
periodType,
periodLabel,
localStart,
localEnd,
income,
expense,
income - expense,
list.Count,
aiRatio,
peak is null
? null
: groupByMonth
? $"{peak.Key.Month}月"
: $"{peak.Key.Month}月{peak.Key.Day}日",
peak?.Sum(item => item.Amount) ?? 0,
peak?.OrderByDescending(item => item.Amount).First().Note,
topCategory?.Name,
topCategory?.Amount ?? 0,
expense == 0 || topCategory is null
? 0
: (double)(topCategory.Amount / expense * 100),
categoryRanking,
commentary);
}
}
[ApiController]
[Authorize]
[Route("api/search")]
public class SearchController(AppDbContext db, LedgerResolver ledgers) : ControllerBase
{
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
/// <summary>账单搜索:过滤全部在数据库执行,使用时间和 ID 作为稳定游标。</summary>
[HttpGet]
public async Task<ActionResult<List<TransactionDto>>> Search(
[FromQuery] string? q,
[FromQuery] long? ledgerId,
[FromQuery] long? categoryId,
[FromQuery] string? type,
[FromQuery] decimal? minAmount,
[FromQuery] decimal? maxAmount,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] bool aiOnly = false,
[FromQuery] DateTime? beforeOccurredAt = null,
[FromQuery] long? beforeId = null,
[FromQuery] int limit = 50)
{
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"));
var query = db.Transactions.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value);
if (!string.IsNullOrWhiteSpace(q))
{
var keyword = q.Trim();
query = query.Where(t =>
(t.Note != null && t.Note.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 (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));
if (to.HasValue) query = query.Where(t => t.OccurredAt < NormalizeTime(to.Value));
if (aiOnly) query = query.Where(t => TransactionSourceRules.AiAssisted.Contains(t.Source));
if (beforeOccurredAt.HasValue)
{
var cursorTime = NormalizeTime(beforeOccurredAt.Value);
query = query.Where(t =>
t.OccurredAt < cursorTime ||
(t.OccurredAt == cursorTime && (!beforeId.HasValue || t.Id < beforeId.Value)));
}
var list = await query
.OrderByDescending(t => t.OccurredAt)
.ThenByDescending(t => t.Id)
.Take(Math.Clamp(limit, 1, 100))
.ToListAsync();
return Ok(list.Select(t => TransactionsController.ToDto(t, t.Category)).ToList());
}
private static DateTime NormalizeTime(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
}
@@ -0,0 +1,544 @@
using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/transactions")]
public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : ControllerBase
{
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
/// <summary>手动记一笔</summary>
[HttpPost]
public async Task<ActionResult<TransactionDto>> Create(CreateTransactionRequest req)
{
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"));
var clientRequestId = string.IsNullOrWhiteSpace(req.ClientRequestId)
? null
: req.ClientRequestId.Trim();
if (clientRequestId?.Length > 64)
return BadRequest(new ApiError("CLIENT_REQUEST_ID_INVALID", "幂等标识最长 64 个字符"));
if (clientRequestId != null)
{
var existing = await db.Transactions
.Include(t => t.Category)
.FirstOrDefaultAsync(t =>
t.UserId == Uid && t.ClientRequestId == clientRequestId);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
}
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
if (!ledgerId.HasValue)
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.UserId == null || c.UserId == Uid));
if (cat is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
var tx = new Transaction
{
LedgerId = ledgerId.Value,
UserId = Uid,
CategoryId = cat.Id,
OccurredAt = req.OccurredAt.HasValue
? NormalizeOccurredAt(req.OccurredAt.Value)
: DateTime.UtcNow,
Type = type.Value,
Amount = req.Amount,
Note = req.Note,
PaymentMethod = req.PaymentMethod,
Source = req.Source switch
{
"voice" => TransactionSource.Voice,
"ocr" => TransactionSource.ReceiptOcr,
"screenshot" => TransactionSource.Screenshot,
"accessibility" => TransactionSource.Accessibility,
"notification" => TransactionSource.Notification,
"recognition_ai" => TransactionSource.RecognitionAi,
"local_ocr" => TransactionSource.LocalOcr,
_ => TransactionSource.Manual,
},
SourceText = req.SourceText,
ClientRequestId = clientRequestId,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.Transactions.Add(tx);
try
{
await db.SaveChangesAsync();
return Ok(ToDto(tx, cat));
}
catch (DbUpdateException) when (clientRequestId is not null)
{
// 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);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
throw;
}
}
/// <summary>账单详情(P11:AI 来源追溯、分类、备注、时间)</summary>
[HttpGet("{id:long}")]
public async Task<ActionResult<TransactionDto>> Detail(long id)
{
var tx = await db.Transactions.Include(t => t.Category)
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在"));
return Ok(ToDto(tx, tx.Category));
}
[HttpPut("{id:long}")]
public async Task<ActionResult<TransactionDto>> Update(long id, UpdateTransactionRequest req)
{
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"));
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.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);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在"));
if (HasVersionConflict(tx, req.BaseUpdatedAt))
return Conflict(new
{
code = "SYNC_CONFLICT",
message = "账单已在其他设备修改,请选择保留本地或云端版本",
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
});
tx.LedgerId = ledgerId.Value;
tx.CategoryId = category.Id;
tx.Category = category;
tx.Type = type.Value;
tx.Amount = req.Amount;
tx.Note = req.Note?.Trim();
tx.PaymentMethod = req.PaymentMethod?.Trim();
tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
tx.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToDto(tx, category));
}
/// <summary>撤销/删除(软删除,决策 18</summary>
[HttpDelete("{id:long}")]
public async Task<IActionResult> Delete(long id, [FromQuery] DateTime? baseUpdatedAt = null)
{
var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在"));
if (HasVersionConflict(tx, baseUpdatedAt))
return Conflict(new
{
code = "SYNC_CONFLICT",
message = "账单已在其他设备修改,请确认后再删除",
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? tx.Category),
}); tx.IsDeleted = true;
tx.DeletedAt = DateTime.UtcNow;
tx.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return NoContent();
}
[HttpGet("recycle-bin")]
public async Task<ActionResult<List<TransactionDto>>> RecycleBin(
[FromQuery] long? ledgerId = null,
[FromQuery] long? beforeId = null,
[FromQuery] int limit = 50)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var query = db.Transactions
.IgnoreQueryFilters()
.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.IsDeleted);
if (beforeId.HasValue) query = query.Where(t => t.Id < beforeId.Value);
var items = await query.OrderByDescending(t => t.DeletedAt)
.ThenByDescending(t => t.Id)
.Take(Math.Clamp(limit, 1, 100))
.ToListAsync();
return Ok(items.Select(t => ToDto(t, t.Category)).ToList());
}
[HttpPost("{id:long}/restore")]
public async Task<ActionResult<TransactionDto>> Restore(long id, [FromQuery] DateTime? baseUpdatedAt = null)
{
var tx = await db.Transactions.IgnoreQueryFilters().Include(t => t.Category)
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid && t.IsDeleted);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "回收站中没有这笔账单"));
if (HasVersionConflict(tx, baseUpdatedAt))
return Conflict(new
{
code = "SYNC_CONFLICT",
message = "账单已在其他设备修改,请确认后再恢复",
server = ToDto(tx, tx.Category),
}); tx.IsDeleted = false;
tx.DeletedAt = null;
tx.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToDto(tx, tx.Category));
}
[HttpDelete("{id:long}/permanent")]
public async Task<IActionResult> PermanentDelete(long id)
{
var tx = await db.Transactions.IgnoreQueryFilters()
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid && t.IsDeleted);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "回收站中没有这笔账单"));
await PermanentDeleteCore(tx);
await db.SaveChangesAsync();
return NoContent();
}
[HttpDelete("recycle-bin")]
public async Task<IActionResult> ClearRecycleBin([FromQuery] long? ledgerId = null)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var items = await db.Transactions.IgnoreQueryFilters()
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.IsDeleted)
.ToListAsync();
foreach (var item in items) await PermanentDeleteCore(item);
await db.SaveChangesAsync();
return NoContent();
}
/// <summary>月账单(按日分组,首页明细用)</summary>
[HttpGet("month")]
public async Task<ActionResult<MonthSummaryDto>> Month([FromQuery] int year, [FromQuery] int month, [FromQuery] long? ledgerId = null)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var q = db.Transactions
.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
t.OccurredAt >= start && t.OccurredAt < end);
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 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.Select(t => ToDto(t, t.Category)).ToList()))
.ToList();
return Ok(new MonthSummaryDto(year, month, income, expense, income - expense, list.Count, days));
}
/// <summary>月统计(统计页:分类占比 + 每日趋势 + AI 分析)</summary>
[HttpGet("stats")]
public async Task<ActionResult<MonthStatsDto>> Stats(
[FromQuery] int year,
[FromQuery] int month,
[FromQuery] long? ledgerId = null)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var daysInMonth = DateTime.DaysInMonth(year, month);
var list = await db.Transactions
.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
t.OccurredAt >= start && t.OccurredAt < end)
.ToListAsync();
var expenses = list.Where(t => t.Type == TransactionType.Expense).ToList();
var totalExpense = expenses.Sum(t => t.Amount);
var totalIncome = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
var byCat = expenses
.GroupBy(t => t.Category)
.Select(g => new CategoryStatDto(
g.Key.Id, g.Key.Name, g.Key.IconKey,
g.Sum(t => t.Amount),
totalExpense == 0 ? 0 : (double)(g.Sum(t => t.Amount) / totalExpense * 100),
g.Key.ColorKey))
.OrderByDescending(c => c.Amount)
.ToList();
var daily = Enumerable.Range(1, daysInMonth)
.Select(d => expenses.Where(t => ChinaClock.ToLocal(t.OccurredAt).Day == d).Sum(t => t.Amount))
.ToList();
// AI 分析卡文案
var topCat = byCat.FirstOrDefault();
var prevMonth = month == 1 ? 12 : month - 1;
var prevYear = month == 1 ? year - 1 : year;
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)
.SumAsync(t => t.Amount);
var trend = prevExpense == 0 ? "这是你的第一个月记账哦"
: totalExpense > prevExpense * 1.05m ? $"比上月多花了 ¥{(totalExpense - prevExpense):F0}"
: totalExpense < prevExpense * 0.95m ? $"比上月省了 ¥{(prevExpense - totalExpense):F0}" : "和上月差不多";
var aiText = topCat is null
? "这个月还没开始花钱呢,快去记一笔吧"
: $"这个月「{topCat.Name}」花最多,共 ¥{topCat.Amount:F0}(占 {topCat.Percent:F0}%),{trend}。";
return Ok(new MonthStatsDto(year, month, totalExpense, totalIncome, byCat, daily) { AiAnalysis = aiText });
}
[HttpGet("stats/period")]
public async Task<ActionResult<PeriodStatsDto>> PeriodStats(
[FromQuery] string period = "month",
[FromQuery] DateTime? anchor = null,
[FromQuery] long? ledgerId = null)
{
var normalizedPeriod = period.Trim().ToLowerInvariant();
if (normalizedPeriod is not ("week" or "month" or "year"))
return BadRequest(new ApiError(
"PERIOD_INVALID",
"统计周期必须是 week、month 或 year"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError(
"LEDGER_NOT_FOUND",
"账本不存在或无权访问"));
var selected = DateTime.SpecifyKind(
(anchor ?? ChinaClock.Now).Date,
DateTimeKind.Unspecified);
DateTime localStart;
DateTime localEnd;
DateTime previousLocalStart;
string periodLabel;
switch (normalizedPeriod)
{
case "week":
{
var offset = ((int)selected.DayOfWeek + 6) % 7;
localStart = selected.AddDays(-offset);
localEnd = localStart.AddDays(7);
previousLocalStart = localStart.AddDays(-7);
periodLabel = $"{localStart:MM月dd日} - {localEnd.AddDays(-1):MM月dd日}";
break;
}
case "year":
localStart = new DateTime(selected.Year, 1, 1);
localEnd = localStart.AddYears(1);
previousLocalStart = localStart.AddYears(-1);
periodLabel = $"{localStart.Year}年";
break;
default:
localStart = new DateTime(selected.Year, selected.Month, 1);
localEnd = localStart.AddMonths(1);
previousLocalStart = localStart.AddMonths(-1);
periodLabel = $"{localStart.Year}年{localStart.Month}月";
break;
}
var start = ChinaClock.ToUtc(localStart);
var end = ChinaClock.ToUtc(localEnd);
var previousStart = ChinaClock.ToUtc(previousLocalStart);
var list = await db.Transactions
.Include(transaction => transaction.Category)
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == resolvedLedgerId.Value &&
transaction.OccurredAt >= start &&
transaction.OccurredAt < end)
.ToListAsync();
var expenses = list
.Where(transaction => transaction.Type == TransactionType.Expense)
.ToList();
var totalExpense = expenses.Sum(transaction => transaction.Amount);
var totalIncome = list
.Where(transaction => transaction.Type == TransactionType.Income)
.Sum(transaction => transaction.Amount);
var byCategory = expenses
.GroupBy(transaction => transaction.Category)
.Select(group => new CategoryStatDto(
group.Key.Id,
group.Key.Name,
group.Key.IconKey,
group.Sum(transaction => transaction.Amount),
totalExpense == 0
? 0
: (double)(group.Sum(transaction => transaction.Amount) / totalExpense * 100),
group.Key.ColorKey))
.OrderByDescending(item => item.Amount)
.ThenBy(item => item.Name)
.ToList();
List<PeriodTrendPointDto> trend;
if (normalizedPeriod == "year")
{
trend = Enumerable.Range(0, 12)
.Select(index =>
{
var pointStart = localStart.AddMonths(index);
var pointEnd = pointStart.AddMonths(1);
var pointItems = list.Where(transaction =>
{
var local = ChinaClock.ToLocal(transaction.OccurredAt);
return local >= pointStart && local < pointEnd;
});
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));
})
.ToList();
}
else
{
var dayCount = (localEnd - localStart).Days;
trend = Enumerable.Range(0, dayCount)
.Select(index =>
{
var date = localStart.AddDays(index);
var dayItems = list.Where(transaction =>
ChinaClock.ToLocal(transaction.OccurredAt).Date == date.Date);
var label = normalizedPeriod == "week"
? new[] { "周一", "周二", "周三", "周四", "周五", "周六", "周日" }[index]
: $"{date.Day}日";
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));
})
.ToList();
}
var previousExpense = await db.Transactions
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == resolvedLedgerId.Value &&
transaction.Type == TransactionType.Expense &&
transaction.OccurredAt >= previousStart &&
transaction.OccurredAt < start)
.SumAsync(transaction => transaction.Amount);
var topCategory = byCategory.FirstOrDefault();
string analysis;
if (topCategory is null)
{
analysis = "这个周期还没有支出记录";
}
else if (previousExpense == 0)
{
analysis = $"{topCategory.Name}支出最多,共 ¥{topCategory.Amount:F0}";
}
else
{
var difference = totalExpense - previousExpense;
analysis = Math.Abs(difference) < previousExpense * 0.05m
? $"{topCategory.Name}支出最多,与上个周期基本持平"
: difference > 0
? $"{topCategory.Name}支出最多,比上个周期多 ¥{difference:F0}"
: $"{topCategory.Name}支出最多,比上个周期少 ¥{-difference:F0}";
}
return Ok(new PeriodStatsDto(
normalizedPeriod,
periodLabel,
localStart,
localEnd,
totalExpense,
totalIncome,
totalIncome - totalExpense,
list.Count,
byCategory,
trend,
analysis));
}
private async Task PermanentDeleteCore(Transaction transaction)
{
var cards = await db.ChatMessages
.Where(message => message.TransactionId == transaction.Id)
.ToListAsync();
foreach (var card in cards)
{
card.TransactionId = null;
card.Content = """{"deleted":true}""";
}
db.Transactions.Remove(transaction);
}
private static TransactionType? ParseType(string? value) => value switch
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
_ => null,
};
private static bool HasVersionConflict(Transaction transaction, DateTime? baseUpdatedAt)
{
if (!baseUpdatedAt.HasValue) return false;
var baseline = baseUpdatedAt.Value.Kind == DateTimeKind.Utc
? baseUpdatedAt.Value
: baseUpdatedAt.Value.ToUniversalTime();
return transaction.UpdatedAt > baseline.AddMilliseconds(1);
}
private static DateTime NormalizeOccurredAt(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
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.Amount,
t.Source == TransactionSource.AiChat
&& !string.IsNullOrWhiteSpace(t.SourceText)
&& string.Equals(t.Note?.Trim(), t.SourceText.Trim(), StringComparison.Ordinal)
? TransactionNoteFormatter.BuildNote(t.SourceText, c.Name)
: t.Note,
t.PaymentMethod, t.OccurredAt,
t.Source switch
{
TransactionSource.AiChat => "ai_chat",
TransactionSource.Voice => "voice",
TransactionSource.ReceiptOcr => "ocr",
TransactionSource.Screenshot => "screenshot",
TransactionSource.Accessibility => "accessibility",
TransactionSource.Notification => "notification",
TransactionSource.RecognitionAi => "recognition_ai",
TransactionSource.LocalOcr => "local_ocr",
_ => "manual",
},
t.SourceText,
t.IsDeleted,
c.ColorKey,
t.UpdatedAt);
}
@@ -0,0 +1,370 @@
using System.IO.Compression;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/users/me")]
public class UsersController(
AppDbContext db,
JwtService jwt,
AiPermissionService aiPermissions) : ControllerBase
{
private long CurrentUserId =>
long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub")!);
/// <summary>当前用户信息(含 AI 伙伴设置与模式)</summary>
[HttpGet]
public async Task<ActionResult<UserProfileResponse>> Me()
{
var user = await db.Users
.Include(u => u.AiCompanion)
.Include(u => u.FeaturePermissions)
.FirstOrDefaultAsync(u => u.Id == CurrentUserId);
if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在"));
return Ok(ToProfile(user));
}
/// <summary>完成首次引导:选模式 + 选 AI 伙伴(形象/性格/昵称)</summary>
[HttpPost("onboarding")]
public async Task<ActionResult<UserProfileResponse>> Onboarding(OnboardingRequest req)
{
var user = await db.Users
.Include(u => u.AiCompanion)
.Include(u => u.FeaturePermissions)
.FirstOrDefaultAsync(u => u.Id == CurrentUserId);
if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在"));
if (!await aiPermissions.IsEnabledAsync(CurrentUserId))
{
user.AppMode = AppMode.Normal;
await db.SaveChangesAsync();
return Ok(ToProfile(user));
}
if (!await db.AiAvatars.AnyAsync(a => a.Key == req.AvatarKey && a.IsEnabled))
return BadRequest(new ApiError("AVATAR_INVALID", "形象不存在"));
if (!await db.AiPersonas.AnyAsync(p => p.Key == req.PersonaKey && p.IsEnabled))
return BadRequest(new ApiError("PERSONA_INVALID", "性格不存在"));
user.AppMode = req.AppMode == "ai" ? AppMode.AiFirst : AppMode.Normal;
user.AiCompanion ??= new AiCompanionSetting { UserId = user.Id };
user.AiCompanion.AvatarKey = req.AvatarKey;
user.AiCompanion.PersonaKey = req.PersonaKey;
user.AiCompanion.CustomName = string.IsNullOrWhiteSpace(req.CustomName) ? null : req.CustomName.Trim();
await db.SaveChangesAsync();
return Ok(ToProfile(user));
}
/// <summary>切换 App 模式(决策:双模式可随时切换)</summary>
[HttpPut("mode")]
public async Task<ActionResult<UserProfileResponse>> SwitchMode(SwitchModeRequest req)
{
var user = await db.Users
.Include(u => u.AiCompanion)
.Include(u => u.FeaturePermissions)
.FirstOrDefaultAsync(u => u.Id == CurrentUserId);
if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在"));
if (req.AppMode == "ai" && !await aiPermissions.IsEnabledAsync(CurrentUserId))
return StatusCode(403, new ApiError("AI_PERMISSION_DENIED", "当前账号未开通 AI 功能"));
user.AppMode = req.AppMode == "ai" ? AppMode.AiFirst : AppMode.Normal;
await db.SaveChangesAsync();
return Ok(ToProfile(user));
}
/// <summary>更新 AI 伙伴设置(性格设置页 P5</summary>
[HttpPut("companion")]
public async Task<ActionResult<UserProfileResponse>> UpdateCompanion(UpdateCompanionRequest req)
{
var user = await db.Users
.Include(u => u.AiCompanion)
.Include(u => u.FeaturePermissions)
.FirstOrDefaultAsync(u => u.Id == CurrentUserId);
if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在"));
if (!await aiPermissions.IsEnabledAsync(CurrentUserId))
return StatusCode(403, new ApiError("AI_PERMISSION_DENIED", "当前账号未开通 AI 功能"));
if (user.AiCompanion is null) return BadRequest(new ApiError("ONBOARDING_REQUIRED", "请先完成引导"));
if (req.AvatarKey != null)
{
if (!await db.AiAvatars.AnyAsync(a => a.Key == req.AvatarKey && a.IsEnabled))
return BadRequest(new ApiError("AVATAR_INVALID", "形象不存在"));
user.AiCompanion.AvatarKey = req.AvatarKey;
}
if (req.PersonaKey != null)
{
if (!await db.AiPersonas.AnyAsync(p => p.Key == req.PersonaKey && p.IsEnabled))
return BadRequest(new ApiError("PERSONA_INVALID", "性格不存在"));
user.AiCompanion.PersonaKey = req.PersonaKey;
}
if (req.CustomName != null)
user.AiCompanion.CustomName = string.IsNullOrWhiteSpace(req.CustomName) ? null : req.CustomName.Trim();
if (req.RoastLevel is >= 0 and <= 100) user.AiCompanion.RoastLevel = req.RoastLevel.Value;
if (req.StickerFrequency is >= 0 and <= 100) user.AiCompanion.StickerFrequency = req.StickerFrequency.Value;
if (req.ProactiveLevel is >= 0 and <= 100) user.AiCompanion.ProactiveLevel = req.ProactiveLevel.Value;
await db.SaveChangesAsync();
return Ok(ToProfile(user));
}
[HttpPut("profile")]
public async Task<ActionResult<UserProfileResponse>> UpdateProfile(UpdateProfileRequest req)
{
var user = await db.Users.Include(u => u.AiCompanion).Include(u => u.FeaturePermissions)
.FirstOrDefaultAsync(u => u.Id == CurrentUserId);
if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在"));
var nickname = req.Nickname?.Trim();
if (nickname?.Length > 32)
return BadRequest(new ApiError("NICKNAME_INVALID", "昵称最多 32 个字"));
user.Nickname = string.IsNullOrWhiteSpace(nickname) ? null : nickname;
await db.SaveChangesAsync();
return Ok(ToProfile(user));
}
[HttpPut("password")]
public async Task<ActionResult<AuthResponse>> ChangePassword(ChangePasswordRequest req)
{
if (req.NewPassword.Length < 6)
return BadRequest(new ApiError("PASSWORD_TOO_SHORT", "新密码至少 6 位"));
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == CurrentUserId);
if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在"));
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash))
return Unauthorized(new ApiError("BAD_CREDENTIALS", "当前密码错误"));
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
user.AuthVersion++;
await db.SaveChangesAsync();
var (token, expires) = jwt.Issue(user);
return Ok(new AuthResponse(user.Id, user.Username, token, expires));
}
[HttpGet("export")]
public async Task<IActionResult> Export()
{
var userId = CurrentUserId;
var user = await db.Users.Include(u => u.AiCompanion).Include(u => u.FeaturePermissions)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在"));
var ledgers = await db.Ledgers.Where(l => l.OwnerId == userId)
.OrderBy(l => l.Id).ToListAsync();
var transactions = await db.Transactions.IgnoreQueryFilters()
.Include(t => t.Category)
.Where(t => t.UserId == userId)
.OrderBy(t => t.Id).ToListAsync();
var budgets = await db.Budgets.Where(b => b.UserId == userId)
.OrderBy(b => b.Id).ToListAsync();
var categories = await db.Categories
.Where(c => c.UserId == null || c.UserId == userId)
.OrderBy(c => c.Type).ThenBy(c => c.SortOrder).ToListAsync();
var messages = await db.ChatMessages.Where(m => m.UserId == userId)
.OrderBy(m => m.Id).ToListAsync();
var snapshot = new
{
formatVersion = 1,
exportedAt = DateTime.UtcNow,
timezone = "Asia/Shanghai",
user = new
{
user.Id,
user.Username,
user.Nickname,
appMode = user.AppMode.ToString(),
user.CreatedAt,
companion = user.AiCompanion is null ? null : new
{
user.AiCompanion.AvatarKey,
user.AiCompanion.CustomName,
user.AiCompanion.PersonaKey,
user.AiCompanion.RoastLevel,
user.AiCompanion.StickerFrequency,
},
},
ledgers = ledgers.Select(l => new
{
l.Id, l.Name, l.IconKey, l.IsDefault, l.CreatedAt,
}),
categories = categories.Select(c => new
{
c.Id, c.UserId, type = c.Type.ToString().ToLowerInvariant(),
c.Name, c.IconKey, c.ColorKey, c.SortOrder, c.IsDeleted,
}),
transactions = transactions.Select(t => new
{
t.Id, t.LedgerId, t.CategoryId,
type = t.Type.ToString().ToLowerInvariant(),
t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
source = t.Source.ToString(), t.SourceText,
t.IsDeleted, t.DeletedAt, t.CreatedAt, t.UpdatedAt,
}),
budgets = budgets.Select(b => new
{
b.Id, b.LedgerId, b.CategoryId, b.Period, b.Amount,
}),
chatMessages = messages.Select(m => new
{
m.Id, role = m.Role.ToString(), type = m.Type.ToString(),
m.Content, m.TransactionId, m.CreatedAt,
}),
};
var ledgerNames = ledgers.ToDictionary(l => l.Id, l => l.Name);
var transactionCsv = new StringBuilder(
"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.Amount),
Csv(tx.Category.Name),
Csv(tx.Note),
Csv(tx.PaymentMethod),
Csv(ChinaClock.ToLocal(tx.OccurredAt).ToString("yyyy-MM-dd HH:mm:ss")),
Csv(tx.Source.ToString()),
Csv(tx.IsDeleted ? "是" : "否"),
}).Append("\r\n");
}
var categoryNames = categories.ToDictionary(c => c.Id, c => c.Name);
var budgetCsv = new StringBuilder("ID,账本,周期,分类,金额\r\n");
foreach (var budget in budgets)
{
budgetCsv.AppendJoin(',', new[]
{
Csv(budget.Id),
Csv(ledgerNames.GetValueOrDefault(budget.LedgerId, "")),
Csv(budget.Period == 0 ? "周期预算" : budget.Period.ToString()),
Csv(budget.CategoryId.HasValue
? categoryNames.GetValueOrDefault(budget.CategoryId.Value, "")
: "总预算"),
Csv(budget.Amount),
}).Append("\r\n");
}
await using var output = new MemoryStream();
using (var archive = new ZipArchive(output, ZipArchiveMode.Create, true))
{
await WriteEntry(archive, "transactions.csv", transactionCsv.ToString(), new UTF8Encoding(true));
await WriteEntry(archive, "budgets.csv", budgetCsv.ToString(), new UTF8Encoding(true));
var json = JsonSerializer.Serialize(
snapshot,
new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await WriteEntry(archive, "backup.json", json, new UTF8Encoding(false));
}
return File(
output.ToArray(),
"application/zip",
$"miaoji-export-{ChinaClock.Now:yyyyMMdd-HHmmss}.zip");
}
[HttpPost("closure")]
public Task<IActionResult> RequestAccountClosure(
DeleteAccountRequest request) =>
ScheduleAccountClosure(request, requireConfirmation: true);
// 兼容旧客户端:不再立即物理删除,统一进入 15 天注销等待期。
[HttpDelete]
public Task<IActionResult> DeleteAccount(DeleteAccountRequest request) =>
ScheduleAccountClosure(request, requireConfirmation: false);
private async Task<IActionResult> ScheduleAccountClosure(
DeleteAccountRequest request,
bool requireConfirmation)
{
var user = await db.Users.FirstOrDefaultAsync(
item => item.Id == CurrentUserId);
if (user is null)
return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在"));
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ApiError("BAD_CREDENTIALS", "密码错误"));
if (requireConfirmation &&
request.ConfirmationText?.Trim() != "注销账号")
return BadRequest(new ApiError(
"CONFIRMATION_INVALID",
"请输入“注销账号”完成确认"));
if (!user.AccountClosureScheduledAt.HasValue)
{
var now = DateTime.UtcNow;
user.AccountClosureRequestedAt = now;
user.AccountClosureScheduledAt = now.AddDays(15);
user.AuthVersion++;
await db.SaveChangesAsync();
}
return Accepted(new AccountClosureResponse(
user.AccountClosureRequestedAt!.Value,
user.AccountClosureScheduledAt.Value));
}
private static async Task WriteEntry(
ZipArchive archive,
string name,
string content,
Encoding encoding)
{
var entry = archive.CreateEntry(name, CompressionLevel.Fastest);
await using var stream = entry.Open();
await using var writer = new StreamWriter(stream, encoding);
await writer.WriteAsync(content);
}
private static string Csv(object? value)
{
var text = value switch
{
null => "",
IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture),
_ => value.ToString() ?? "",
};
if (text.Length > 0 && "=+-@".Contains(text[0])) text = "'" + text;
return "\"" + text.Replace("\"", "\"\"") + "\"";
}
private static UserProfileResponse ToProfile(User u)
{
var aiEnabled = u.FeaturePermissions
.FirstOrDefault(x => x.PermissionKey == FeaturePermissionKeys.Ai)?.IsEnabled ?? true;
var quota = AiChatQuotaService.GetStatus(u);
return new UserProfileResponse(
u.Id,
u.Username,
u.Nickname,
u.AppMode == AppMode.AiFirst ? "ai" : "normal",
u.AiCompanion is null
? null
: new AiCompanionDto(
u.AiCompanion.AvatarKey,
u.AiCompanion.PersonaKey,
u.AiCompanion.CustomName,
u.AiCompanion.RoastLevel,
u.AiCompanion.StickerFrequency,
u.AiCompanion.ProactiveLevel),
u.AiCompanion is not null || !aiEnabled,
new UserPermissionsDto(aiEnabled),
new AiChatQuotaDto(
quota.Limit,
quota.Used,
quota.Remaining,
AiChatQuotaService.PeriodKey(quota.Period),
quota.ResetAt));
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MiaoJiZhang.Domain\MiaoJiZhang.Domain.csproj" />
<ProjectReference Include="..\MiaoJiZhang.Infrastructure\MiaoJiZhang.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@MiaoJiZhang.Api_HostAddress = http://localhost:5169
GET {{MiaoJiZhang.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
+151
View File
@@ -0,0 +1,151 @@
using System.Text;
using System.Threading.RateLimiting;
using MiaoJiZhang.Api.Services;
using Microsoft.AspNetCore.RateLimiting;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
options.AddPolicy("ai", context =>
RateLimitPartition.GetConcurrencyLimiter(
context.User.FindFirst("sub")?.Value ??
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new ConcurrencyLimiterOptions
{
PermitLimit = 2,
QueueLimit = 0,
}));
options.AddPolicy("upload", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.User.FindFirst("sub")?.Value ??
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
});
builder.Services.AddSingleton<JwtService>();
builder.Services.AddScoped<AgentService>();
builder.Services.AddScoped<BudgetRecommendationService>();
builder.Services.AddScoped<ReplyService>();
builder.Services.AddScoped<LedgerResolver>();
builder.Services.AddScoped<AiPermissionService>();
builder.Services.AddScoped<AiChatQuotaService>();
builder.Services.AddScoped<AiPermissionFilter>();
builder.Services.AddHttpClient("LlmClient");
builder.Services.AddSingleton<OpenAiVisionClient>();
builder.Services.AddSingleton<ILlmClient>(sp => sp.GetRequiredService<OpenAiVisionClient>());
builder.Services.AddHostedService<RecycleBinCleanupService>();
builder.Services.AddScoped<AccountDataEraser>();
builder.Services.AddHostedService<AccountClosureCleanupService>();
var conn = builder.Configuration.GetConnectionString("Default");
if (string.IsNullOrWhiteSpace(conn))
throw new InvalidOperationException("必须通过 ConnectionStrings__Default 配置数据库连接");
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 位的管理密钥");
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseMySql(conn, ServerVersion.AutoDetect(conn)));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtSecret)),
};
o.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var idValue = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? context.Principal?.FindFirst("sub")?.Value;
if (!long.TryParse(idValue, out var userId))
{
context.Fail("Invalid user identity");
return;
}
var versionValue = context.Principal?.FindFirst("auth_version")?.Value;
var tokenVersion = int.TryParse(versionValue, out var parsedVersion)
? parsedVersion
: 0;
var db = context.HttpContext.RequestServices.GetRequiredService<AppDbContext>();
var userState = await db.Users
.AsNoTracking()
.Where(user => user.Id == userId)
.Select(user => new
{
user.AuthVersion,
user.IsBanned,
user.AccountClosureScheduledAt,
})
.FirstOrDefaultAsync(context.HttpContext.RequestAborted);
if (userState is null || userState.IsBanned ||
userState.AccountClosureScheduledAt != null ||
userState.AuthVersion != tokenVersion)
{
context.Fail("Account session has been revoked");
}
},
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
if (app.Environment.IsDevelopment())
await DbSeeder.SeedAsync(db);
}
if (app.Environment.IsDevelopment())
app.MapOpenApi();
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.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 }));
app.UseDefaultFiles();
app.UseStaticFiles();
app.Run();
public partial class Program;
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MiaoJiZhang.Api.Tests")]
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5169",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7241;http://localhost:5169",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,50 @@
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public class AccountClosureCleanupService(
IServiceScopeFactory scopeFactory,
ILogger<AccountClosureCleanupService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
await Cleanup(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
await Cleanup(stoppingToken);
}
private async Task Cleanup(CancellationToken ct)
{
try
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var eraser =
scope.ServiceProvider.GetRequiredService<AccountDataEraser>();
var ids = await db.Users
.Where(user =>
user.AccountClosureScheduledAt.HasValue &&
user.AccountClosureScheduledAt <= DateTime.UtcNow)
.Select(user => user.Id)
.ToListAsync(ct);
foreach (var userId in ids)
{
await eraser.EraseAsync(userId, ct);
logger.LogInformation(
"Permanently removed account {UserId} after closure grace period",
userId);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
catch (Exception exception)
{
logger.LogError(exception, "Account closure cleanup failed");
}
}
}
@@ -0,0 +1,37 @@
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public class AccountDataEraser(AppDbContext db)
{
public async Task EraseAsync(long userId, CancellationToken ct = default)
{
await using var transaction =
await db.Database.BeginTransactionAsync(ct);
await db.ChatMessages
.Where(message => message.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.Transactions
.IgnoreQueryFilters()
.Where(item => item.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.Budgets
.Where(item => item.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.Categories
.Where(item => item.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.AiCompanionSettings
.Where(item => item.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.Ledgers
.Where(item => item.OwnerId == userId)
.ExecuteDeleteAsync(ct);
await db.Users
.Where(item => item.Id == userId)
.ExecuteDeleteAsync(ct);
await transaction.CommitAsync(ct);
}
}
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace MiaoJiZhang.Api.Services;
/// <summary>
/// 管理后台鉴权:请求头 X-Admin-Key 与 appsettings.Admin:Key 匹配即可。
/// 仅内部使用,不依赖 JWT/用户体系。
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AdminAuthAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
var config = context.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
var key = config["Admin:Key"];
if (string.IsNullOrWhiteSpace(key) ||
!context.HttpContext.Request.Headers.TryGetValue("X-Admin-Key", out var provided) ||
provided != key)
{
context.Result = new UnauthorizedObjectResult(new { error = "admin_key_required", message = "请在 Header 中提供 X-Admin-Key" });
}
}
}
@@ -0,0 +1,749 @@
using System.Globalization;
using System.Text.Json;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public record AgentTurnResult(
string Reply,
IReadOnlyList<Transaction> Transactions);
public class AgentService(
AppDbContext db,
ILlmClient llm,
ILogger<AgentService> logger)
{
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web);
private static readonly object TransactionItemsSchema = Schema(
"""
{
"type": "object",
"properties": {
"items": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["expense", "income"],
"description": "交易方向,收入必须是 income,支出必须是 expense"
},
"amount": {
"type": "number",
"exclusiveMinimum": 0
},
"categoryName": {
"type": "string",
"description": "支出分类:餐饮、饮品、购物、交通、住房、娱乐、医疗、学习、服饰、人情、旅行、其他;收入分类:工资、奖金、理财、兼职、红包、报销、其他"
},
"note": {
"type": "string",
"description": "适合明细列表展示的简短备注,不要照抄用户整句话"
},
"paymentMethod": {
"type": "string",
"description": "可选,例如微信支付、支付宝、现金"
},
"occurredAt": {
"type": "string",
"description": "可选,ISO 8601 时间;未提时间不要填写"
}
},
"required": ["type", "amount", "categoryName", "note"],
"additionalProperties": false
}
}
},
"required": ["items"],
"additionalProperties": false
}
""");
private static readonly IReadOnlyList<AgentToolDefinition> ChatTools =
[
new(
"create_transactions",
"仅当每笔交易的金额和收入/支出方向都明确时,创建一笔或多笔账单。裸金额、方向冲突或不确定时不得调用,应先询问用户。",
TransactionItemsSchema),
new(
"get_financial_summary",
"读取用户默认账本在指定周期内的真实收入、支出、结余和分类汇总。",
Schema(
"""
{
"type": "object",
"properties": {
"period": {
"type": "string",
"enum": ["today", "yesterday", "this_week", "this_month", "last_month", "this_year"]
}
},
"required": ["period"],
"additionalProperties": false
}
""")),
new(
"list_transactions",
"读取用户默认账本的近期真实账单,可按周期、收支方向和分类筛选。",
Schema(
"""
{
"type": "object",
"properties": {
"period": {
"type": "string",
"enum": ["today", "yesterday", "this_week", "this_month", "last_month", "this_year"]
},
"type": {
"type": "string",
"enum": ["expense", "income"]
},
"categoryName": {
"type": "string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 10
}
},
"required": ["period"],
"additionalProperties": false
}
""")),
new(
"get_budget_status",
"读取用户默认账本某月的总预算、分类预算、已花金额和周期预算状态。",
Schema(
"""
{
"type": "object",
"properties": {
"year": {
"type": "integer",
"minimum": 2000,
"maximum": 2200
},
"month": {
"type": "integer",
"minimum": 1,
"maximum": 12
}
},
"required": ["year", "month"],
"additionalProperties": false
}
""")),
];
private static readonly IReadOnlyList<AgentToolDefinition> DraftTools =
[
new(
"prepare_transactions",
"解析明确的自然语言交易草稿。金额或收支方向不明确时不要调用。",
TransactionItemsSchema),
];
public async Task<AgentTurnResult> RunAsync(
long userId,
long ledgerId,
bool allowWrites,
string userText,
ChatMessage sourceMessage,
string personaPrompt,
string conversationContext,
Func<string, CancellationToken, Task>? onToken = null,
Func<string, CancellationToken, Task>? onToolRunning = null,
CancellationToken ct = default)
{
if (!llm.IsEnabled)
throw new InvalidOperationException("AI 功能尚未配置");
if (sourceMessage.Id == 0)
await db.SaveChangesAsync(ct);
var created = new List<Transaction>();
var writeExecuted = false;
var response = await llm.RunAgentAsync(
BuildSystemPrompt(personaPrompt, conversationContext),
userText,
ChatTools,
async (call, toolCt) =>
{
logger.LogInformation(
"Executing AI agent tool. user={UserId} tool={Tool} callId={CallId}",
userId,
call.Name,
call.CallId);
return call.Name switch
{
"create_transactions" when allowWrites => await CreateTransactionsAsync(
userId,
ledgerId,
userText,
sourceMessage,
call.Arguments,
created,
() => writeExecuted,
() => writeExecuted = true,
toolCt),
"create_transactions" => Serialize(new
{
ok = false,
error = "AI 自动入账已关闭,请在记一笔页面手动确认",
}),
"get_financial_summary" => await FinancialSummaryAsync(
userId,
ledgerId,
call.Arguments,
toolCt),
"list_transactions" => await ListTransactionsAsync(
userId,
ledgerId,
call.Arguments,
toolCt),
"get_budget_status" => await BudgetStatusAsync(
userId,
ledgerId,
call.Arguments,
toolCt),
_ => Serialize(new { ok = false, error = "未知工具" }),
};
},
onToken,
onToolRunning,
ct);
return new AgentTurnResult(response.Text, created);
}
public async Task<IReadOnlyList<ParsedBill>> ParseDraftsAsync(
long userId,
string text,
CancellationToken ct = default)
{
if (!llm.IsEnabled)
throw new InvalidOperationException("AI 功能尚未配置");
var drafts = new List<ParsedBill>();
await llm.RunAgentAsync(
"""
稿/ prepare_transactions
稿退
100
""",
text,
DraftTools,
async (call, toolCt) =>
{
if (call.Name != "prepare_transactions")
return Serialize(new { ok = false, error = "未知工具" });
var parsed = await ParseTransactionItemsAsync(
userId,
call.Arguments,
toolCt);
drafts.AddRange(parsed);
return Serialize(new
{
ok = true,
count = parsed.Count,
items = parsed.Select(ToToolItem),
});
},
ct: ct);
return drafts;
}
private string BuildSystemPrompt(string personaPrompt, string conversationContext)
{
var now = ChinaClock.Now;
return $"""
{personaPrompt}
你同时是记之的财务 Agent。当前北京时间:{now:yyyy-MM-dd HH:mm:ss}。
记账必须通过 create_transactions;查询账本必须调用对应只读工具,不能凭空回答金额。
只有每笔金额和收入/支出方向都明确时才允许自动入账。
“我赚了100”“工资到账5000”“收到退款20”是收入;“午饭30”“买衣服200”是支出。
“100”这类裸金额、方向冲突或不确定表达必须先追问,不得默认支出。
同一句有多笔交易时一次传入全部 items,可以同时包含收入和支出。
工具参数 type 是必填项,绝不能省略;工具报错时向用户说明,不得假装已入账。
工具返回的数据是唯一事实来源。回复简短自然,已入账时明确说收入或支出。
不得删除账单、修改历史账单或修改预算。
{conversationContext}
""";
}
private async Task<string> CreateTransactionsAsync(
long userId,
long ledgerId,
string sourceText,
ChatMessage sourceMessage,
string arguments,
List<Transaction> created,
Func<bool> wasExecuted,
Action markExecuted,
CancellationToken ct)
{
if (wasExecuted())
return Serialize(new { ok = false, error = "本轮已经执行过入账,不能重复写入" });
var bills = await ParseTransactionItemsAsync(userId, arguments, ct);
if (bills.Count == 0)
return Serialize(new { ok = false, error = "没有有效交易,未写入" });
markExecuted();
var now = DateTime.UtcNow;
var pending = new List<Transaction>();
foreach (var bill in bills)
{
var category = await db.Categories.FirstAsync(
c => c.Id == bill.CategoryId && c.Type == bill.Type,
ct);
if (category.Type != bill.Type)
throw new InvalidOperationException("交易类型与分类类型不一致");
pending.Add(new Transaction
{
LedgerId = ledgerId,
UserId = userId,
CategoryId = category.Id,
Category = category,
Type = bill.Type,
Amount = bill.Amount,
Note = NormalizeNote(bill.Note, sourceText, category.Name),
PaymentMethod = bill.PaymentMethod,
OccurredAt = bill.OccurredAt ?? now,
Source = TransactionSource.AiChat,
SourceText = sourceText,
SourceChatMessageId = sourceMessage.Id,
CreatedAt = now,
UpdatedAt = now,
});
}
db.Transactions.AddRange(pending);
try
{
await db.SaveChangesAsync(ct);
}
catch
{
foreach (var transaction in pending)
db.Entry(transaction).State = EntityState.Detached;
throw;
}
created.AddRange(pending);
return Serialize(new
{
ok = true,
count = created.Count,
items = created.Select(transaction => new
{
id = transaction.Id,
type = transaction.Type == TransactionType.Income
? "income"
: "expense",
amount = transaction.Amount,
categoryName = transaction.Category.Name,
note = transaction.Note,
}),
});
}
private async Task<List<ParsedBill>> ParseTransactionItemsAsync(
long userId,
string arguments,
CancellationToken ct)
{
using var document = JsonDocument.Parse(arguments);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("items", out var items) ||
items.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("工具参数缺少 items");
}
if (items.GetArrayLength() is < 1 or > 10)
throw new InvalidOperationException("每次只能处理 1 到 10 笔交易");
var bills = new List<ParsedBill>();
foreach (var item in items.EnumerateArray())
{
var typeText = RequiredString(item, "type").ToLowerInvariant();
var type = typeText switch
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
_ => throw new InvalidOperationException(
"交易 type 必须是 income 或 expense"),
};
if (!item.TryGetProperty("amount", out var amountNode) ||
!amountNode.TryGetDecimal(out var amount) ||
amount <= 0)
{
throw new InvalidOperationException("交易金额必须大于 0");
}
var categoryName = RequiredString(item, "categoryName").Trim();
var note = RequiredString(item, "note").Trim();
if (categoryName.Length > 20) categoryName = categoryName[..20];
if (note.Length > 30) note = note[..30];
var category = await ResolveCategoryAsync(
userId,
type,
categoryName,
ct);
string? paymentMethod = null;
if (item.TryGetProperty("paymentMethod", out var paymentNode) &&
paymentNode.ValueKind == JsonValueKind.String)
{
paymentMethod = paymentNode.GetString()?.Trim();
if (paymentMethod?.Length > 20)
paymentMethod = paymentMethod[..20];
}
DateTime? occurredAt = null;
if (item.TryGetProperty("occurredAt", out var occurredNode) &&
occurredNode.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(occurredNode.GetString()))
{
if (!DateTimeOffset.TryParse(
occurredNode.GetString(),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal,
out var parsedTime))
{
throw new InvalidOperationException("交易时间格式无效");
}
occurredAt = parsedTime.UtcDateTime;
}
bills.Add(new ParsedBill(
type,
category.Id,
category.Name,
string.IsNullOrWhiteSpace(note) ? category.Name : note,
amount,
paymentMethod,
occurredAt));
}
return bills;
}
private async Task<string> FinancialSummaryAsync(
long userId,
long ledgerId,
string arguments,
CancellationToken ct)
{
using var document = JsonDocument.Parse(arguments);
var period = RequiredString(document.RootElement, "period");
var (start, end, label) = ResolvePeriod(period);
var transactions = await db.Transactions
.Include(t => t.Category)
.Where(t => t.UserId == userId &&
t.LedgerId == ledgerId &&
t.OccurredAt >= start &&
t.OccurredAt < end)
.ToListAsync(ct);
var income = transactions
.Where(t => t.Type == TransactionType.Income)
.Sum(t => t.Amount);
var expense = transactions
.Where(t => t.Type == TransactionType.Expense)
.Sum(t => t.Amount);
var categories = transactions
.GroupBy(t => new { t.Type, t.Category.Name })
.Select(group => new
{
type = group.Key.Type == TransactionType.Income
? "income"
: "expense",
categoryName = group.Key.Name,
amount = group.Sum(t => t.Amount),
})
.OrderByDescending(item => item.amount)
.ToList();
return Serialize(new
{
ok = true,
period = label,
income,
expense,
balance = income - expense,
count = transactions.Count,
categories,
});
}
private async Task<string> ListTransactionsAsync(
long userId,
long ledgerId,
string arguments,
CancellationToken ct)
{
using var document = JsonDocument.Parse(arguments);
var root = document.RootElement;
var period = RequiredString(root, "period");
var (start, end, label) = ResolvePeriod(period);
var query = db.Transactions
.Include(t => t.Category)
.Where(t => t.UserId == userId &&
t.LedgerId == ledgerId &&
t.OccurredAt >= start &&
t.OccurredAt < end);
if (root.TryGetProperty("type", out var typeNode) &&
typeNode.ValueKind == JsonValueKind.String)
{
var type = typeNode.GetString() switch
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
_ => throw new InvalidOperationException("筛选类型无效"),
};
query = query.Where(t => t.Type == type);
}
if (root.TryGetProperty("categoryName", out var categoryNode) &&
categoryNode.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(categoryNode.GetString()))
{
var categoryName = categoryNode.GetString()!.Trim();
query = query.Where(t => t.Category.Name == categoryName);
}
var limit = root.TryGetProperty("limit", out var limitNode) &&
limitNode.TryGetInt32(out var requestedLimit)
? Math.Clamp(requestedLimit, 1, 10)
: 10;
var transactions = await query
.OrderByDescending(t => t.OccurredAt)
.Take(limit)
.ToListAsync(ct);
return Serialize(new
{
ok = true,
period = label,
count = transactions.Count,
items = transactions.Select(transaction => new
{
id = transaction.Id,
type = transaction.Type == TransactionType.Income
? "income"
: "expense",
transaction.Amount,
categoryName = transaction.Category.Name,
transaction.Note,
occurredAt = transaction.OccurredAt,
}),
});
}
private async Task<string> BudgetStatusAsync(
long userId,
long ledgerId,
string arguments,
CancellationToken ct)
{
using var document = JsonDocument.Parse(arguments);
var root = document.RootElement;
if (!root.TryGetProperty("year", out var yearNode) ||
!yearNode.TryGetInt32(out var year) ||
!root.TryGetProperty("month", out var monthNode) ||
!monthNode.TryGetInt32(out var month) ||
year is < 2000 or > 2200 ||
month is < 1 or > 12)
{
throw new InvalidOperationException("预算年月无效");
}
var period = year * 100 + month;
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var budgetRows = await db.Budgets
.Where(b => b.UserId == userId &&
b.LedgerId == ledgerId &&
(b.Period == period || b.Period == 0))
.ToListAsync(ct);
var effective = budgetRows
.GroupBy(b => b.CategoryId)
.Select(group => group
.OrderByDescending(b => b.Period == period)
.First())
.ToList();
var categoryIds = effective
.Where(budget => budget.CategoryId.HasValue)
.Select(budget => budget.CategoryId!.Value)
.Distinct()
.ToList();
var categoryNames = await db.Categories
.Where(category => categoryIds.Contains(category.Id))
.ToDictionaryAsync(category => category.Id, category => category.Name, ct);
var expenses = await db.Transactions
.Where(t => t.UserId == userId &&
t.LedgerId == ledgerId &&
t.Type == TransactionType.Expense &&
t.OccurredAt >= start &&
t.OccurredAt < end)
.Select(t => new { t.CategoryId, t.Amount })
.ToListAsync(ct);
var spentByCategory = expenses
.GroupBy(t => t.CategoryId)
.ToDictionary(group => group.Key, group => group.Sum(t => t.Amount));
var totalSpent = expenses.Sum(t => t.Amount);
return Serialize(new
{
ok = true,
year,
month,
totalSpent,
budgets = effective.Select(budget => new
{
categoryName = budget.CategoryId.HasValue
? categoryNames.GetValueOrDefault(budget.CategoryId.Value, "其他")
: "总预算",
amount = budget.Amount,
spent = budget.CategoryId.HasValue
? spentByCategory.GetValueOrDefault(budget.CategoryId.Value)
: totalSpent,
remaining = budget.Amount - (
budget.CategoryId.HasValue
? spentByCategory.GetValueOrDefault(budget.CategoryId.Value)
: totalSpent),
recurring = budget.Period == 0,
}),
});
}
private static string NormalizeNote(
string note,
string sourceText,
string categoryName)
{
var trimmed = note.Trim();
var comparableNote = string.Concat(trimmed.Where(char.IsLetterOrDigit));
var comparableSource = string.Concat(
sourceText.Trim().Where(char.IsLetterOrDigit));
if (string.Equals(
comparableNote,
comparableSource,
StringComparison.OrdinalIgnoreCase))
{
return categoryName;
}
return trimmed.Length > 16 ? trimmed[..16] : trimmed;
}
private async Task<Category> ResolveCategoryAsync(
long userId,
TransactionType type,
string requestedName,
CancellationToken ct)
{
var category = await db.Categories
.Where(c => !c.IsDeleted &&
c.Type == type &&
c.Name == requestedName &&
(c.UserId == null || c.UserId == userId))
.OrderByDescending(c => c.UserId == userId)
.FirstOrDefaultAsync(ct);
return category ?? await db.Categories.FirstAsync(
c => !c.IsDeleted &&
c.UserId == null &&
c.Type == type &&
c.Name == "其他",
ct);
}
private async Task<long> DefaultLedgerIdAsync(long userId, CancellationToken ct) =>
await db.Ledgers
.Where(l => l.OwnerId == userId && l.IsDefault)
.Select(l => l.Id)
.FirstAsync(ct);
private static object ToToolItem(ParsedBill bill) => new
{
type = bill.Type == TransactionType.Income ? "income" : "expense",
bill.Amount,
bill.CategoryName,
bill.Note,
bill.PaymentMethod,
occurredAt = bill.OccurredAt,
};
private static string RequiredString(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out var node) ||
node.ValueKind != JsonValueKind.String ||
string.IsNullOrWhiteSpace(node.GetString()))
{
throw new InvalidOperationException($"工具参数缺少 {name}");
}
return node.GetString()!;
}
private static (DateTime Start, DateTime End, string Label) ResolvePeriod(
string period)
{
var localNow = ChinaClock.Now;
var today = localNow.Date;
DateTime startLocal;
DateTime endLocal;
string label;
switch (period)
{
case "today":
startLocal = today;
endLocal = today.AddDays(1);
label = "今天";
break;
case "yesterday":
startLocal = today.AddDays(-1);
endLocal = today;
label = "昨天";
break;
case "this_week":
var offset = ((int)today.DayOfWeek + 6) % 7;
startLocal = today.AddDays(-offset);
endLocal = startLocal.AddDays(7);
label = "本周";
break;
case "this_month":
startLocal = new DateTime(today.Year, today.Month, 1);
endLocal = startLocal.AddMonths(1);
label = "本月";
break;
case "last_month":
endLocal = new DateTime(today.Year, today.Month, 1);
startLocal = endLocal.AddMonths(-1);
label = "上月";
break;
case "this_year":
startLocal = new DateTime(today.Year, 1, 1);
endLocal = startLocal.AddYears(1);
label = "今年";
break;
default:
throw new InvalidOperationException("查询周期无效");
}
return (ChinaClock.ToUtc(startLocal), ChinaClock.ToUtc(endLocal), label);
}
private static object Schema(string json) =>
JsonSerializer.Deserialize<JsonElement>(json);
private static string Serialize(object value) =>
JsonSerializer.Serialize(value, JsonOptions);
}
@@ -0,0 +1,98 @@
using System.Data;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed record AiChatQuotaStatus(
int Limit,
int Used,
int Remaining,
AiQuotaPeriod Period,
DateTime WindowStartedAt,
DateTime ResetAt,
bool Allowed);
public sealed class AiChatQuotaService(AppDbContext db)
{
public async Task<AiChatQuotaStatus> TryConsumeAsync(
long userId,
CancellationToken ct = default)
{
await using var transaction = await db.Database.BeginTransactionAsync(
IsolationLevel.Serializable,
ct);
var user = await db.Users.SingleAsync(item => item.Id == userId, ct);
var status = GetStatus(user);
if (!status.Allowed)
{
await transaction.CommitAsync(ct);
return status;
}
if (user.AiChatWindowStartedAt != status.WindowStartedAt)
{
user.AiChatWindowStartedAt = status.WindowStartedAt;
user.AiChatUsed = 0;
}
user.AiChatUsed++;
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
return GetStatus(user) with { Allowed = true };
}
public static AiChatQuotaStatus GetStatus(User user)
{
var (start, end) = CurrentWindow(user.AiChatPeriod);
var used = user.AiChatWindowStartedAt == start
? Math.Max(0, user.AiChatUsed)
: 0;
var limit = Math.Max(0, user.AiChatLimit);
var remaining = limit == 0 ? -1 : Math.Max(0, limit - used);
return new AiChatQuotaStatus(
limit,
used,
remaining,
user.AiChatPeriod,
start,
end,
limit == 0 || used < limit);
}
public static (DateTime Start, DateTime End) CurrentWindow(
AiQuotaPeriod period)
{
var now = ChinaClock.Now;
var start = period switch
{
AiQuotaPeriod.Week => now.Date.AddDays(-(now.DayOfWeek == DayOfWeek.Sunday
? 6
: (int)now.DayOfWeek - 1)),
AiQuotaPeriod.Month => new DateTime(now.Year, now.Month, 1),
_ => now.Date,
};
var end = period switch
{
AiQuotaPeriod.Week => start.AddDays(7),
AiQuotaPeriod.Month => start.AddMonths(1),
_ => start.AddDays(1),
};
return (ChinaClock.ToUtc(start), ChinaClock.ToUtc(end));
}
public static bool TryParsePeriod(string? value, out AiQuotaPeriod period)
{
period = value?.Trim().ToLowerInvariant() switch
{
"week" => AiQuotaPeriod.Week,
"month" => AiQuotaPeriod.Month,
_ => AiQuotaPeriod.Day,
};
return value?.Trim().ToLowerInvariant() is "day" or "week" or "month";
}
public static string PeriodKey(AiQuotaPeriod period) =>
period.ToString().ToLowerInvariant();
}
@@ -0,0 +1,58 @@
using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public static class FeaturePermissionKeys
{
public const string Ai = "ai";
}
public sealed class AiPermissionService(AppDbContext db)
{
public async Task<bool> IsEnabledAsync(long userId, CancellationToken ct = default)
{
var value = await db.UserFeaturePermissions
.AsNoTracking()
.Where(x => x.UserId == userId && x.PermissionKey == FeaturePermissionKeys.Ai)
.Select(x => (bool?)x.IsEnabled)
.FirstOrDefaultAsync(ct);
// Existing installations remain compatible if a migration has not backfilled yet.
return value ?? true;
}
}
public sealed class RequireAiPermissionAttribute : TypeFilterAttribute
{
public RequireAiPermissionAttribute() : base(typeof(AiPermissionFilter))
{
}
}
public sealed class AiPermissionFilter(AiPermissionService permissions) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var value = context.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.HttpContext.User.FindFirstValue("sub");
if (!long.TryParse(value, out var userId) ||
!await permissions.IsEnabledAsync(userId, context.HttpContext.RequestAborted))
{
context.Result = new ObjectResult(new ApiError(
"AI_PERMISSION_DENIED",
"当前账号暂无 AI 功能权限"))
{
StatusCode = StatusCodes.Status403Forbidden,
};
return;
}
await next();
}
}
@@ -0,0 +1,170 @@
using System.Text.RegularExpressions;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public record ParsedBill(
TransactionType Type,
long CategoryId,
string CategoryName,
string Note,
decimal Amount,
string? PaymentMethod,
DateTime? OccurredAt = null);
public record IntentResult(string Kind, ParsedBill? Bill); // bill | query | chat
/// <summary>
/// Cleans legacy AI notes for list display only. It never decides intent, type, or category.
/// </summary>
public static class TransactionNoteFormatter
{
private static readonly Regex AmountRegex = new(
@"(?<!\d)(\d+(?:\.\d{1,2})?)\s*(?:元|块钱|块|rmb|人民币)?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string BuildNote(string sourceText, string fallback)
{
var note = AmountRegex.Replace(sourceText, " ");
note = Regex.Replace(
note,
@"(?:微信|支付宝|现金)(?:支付|付款|收款)?",
" ");
note = Regex.Replace(
note,
@"^\s*(?:(?:今天|昨天|刚才|刚刚|我|我们)\s*)+",
"");
note = Regex.Replace(
note,
@"^\s*(?:在)?(?:淘宝|京东|拼多多|美团|饿了么)\s*",
"");
note = Regex.Replace(
note,
@"^\s*(?:赚了?|赚到|挣了?|挣到|收到|获得|领到|收入|入账|进账|到账|发了?|发放|报销了?|退款了?|退回|返现)\s*",
"");
note = Regex.Replace(
note,
@"^\s*(?:买了|买|花了|花|付了|支付了|消费了|吃了|吃|喝了|喝)\s*",
"");
note = Regex.Replace(
note,
@"\s*(?:花了|花|用了|付了|支付了|消费了|赚了|挣了|到账|入账)\s*$",
"");
note = Regex.Replace(note, @"[,。!?,.!?::;\s]+", " ").Trim();
if (string.IsNullOrWhiteSpace(note)) note = fallback;
return note.Length > 20 ? note[..20] : note;
}
}
/// <summary>
/// AI 回复文案生成。当前为模板版(性格 × 口癖),后续接 LLM。
/// 性格模板与口癖均来自数据库(后台可配 —— 决策:Prompt 后台可调)。
/// </summary>
public class ReplyService(AppDbContext db)
{
public async Task<string> BillReplyAsync(long userId, ParsedBill bill)
{
var (persona, tic) = await GetPersonaAsync(userId);
var action = bill.Type == TransactionType.Income ? "收入" : "支出";
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.CategoryName} ¥{bill.Amount:F2},钱包回血啦"
: $"记好了!{bill.CategoryName} ¥{bill.Amount:F2},这笔支出我帮你盯着",
};
return ApplyTic(body, persona, tic);
}
public async Task<string> QueryReplyAsync(long userId, decimal topAmount, string topCategory, double percent)
{
var (persona, tic) = await GetPersonaAsync(userId);
var body = persona switch
{
"gentle" => $"这个月「{topCategory}」花得最多,¥{topAmount:F0}(占 {percent:F0}%)~ 要不要我帮你定个小目标呀?",
"strict" => $"本月最大支出分类:{topCategory} ¥{topAmount:F2},占总支出 {percent:F0}%。建议设置分类预算。",
"meme" => $"必须是「{topCategory}」!¥{topAmount:F0},占了 {percent:F0}%,你这钱包是碎钞机吧 2333",
_ => $"是「{topCategory}」¥{topAmount:F0}(占 {percent:F0}%)!继续这么花,月底吃土",
};
return ApplyTic(body, persona, tic);
}
public async Task<string> ChatReplyAsync(long userId)
{
var (persona, tic) = await GetPersonaAsync(userId);
var body = persona == "gentle"
? "嗯嗯我在听~要记账直接说金额就行哦"
: "在听。想记账就直接说,比如「午饭 26」";
return ApplyTic(body, persona, tic);
}
public Task<string> MonthlyRoastAsync(
long userId,
decimal income,
decimal expense,
string topCategory,
decimal topAmount) =>
PeriodRoastAsync(userId, "这个月", income, expense, topCategory, topAmount);
public async Task<string> PeriodRoastAsync(
long userId,
string periodLabel,
decimal income,
decimal expense,
string topCategory,
decimal topAmount)
{
var (persona, tic) = await GetPersonaAsync(userId);
var ratio = income == 0 ? "N/A" : $"{expense / income * 100:F0}%";
var body = persona switch
{
"gentle" => $"{periodLabel}收入 ¥{income:F0}、支出 ¥{expense:F0},「{topCategory}」花了 ¥{topAmount:F0}。我们一起把结余慢慢提高一点吧。",
"strict" => $"{periodLabel}收支比 {ratio}。最大支出分类「{topCategory}」¥{topAmount:F2},建议继续按预算复盘。",
"meme" => $"{periodLabel}收入 ¥{income:F0},支出 ¥{expense:F0},「{topCategory}」就干掉 ¥{topAmount:F0},钱包这波压力不小。",
_ => $"{periodLabel}收入 ¥{income:F0},「{topCategory}」花了 ¥{topAmount:F0}。下个周期这个分类我会继续帮你盯着。",
};
return ApplyTic(body, persona, tic);
}
/// <summary>表情包回复(按性格;用户发表情包时调用)</summary>
public async Task<(string Text, string? StickerKey)> StickerReplyAsync(long userId, string stickerKey)
{
var (persona, tic) = await GetPersonaAsync(userId);
if (stickerKey == "salary")
{
var t = persona == "strict" ? "检测到收入信号。请报具体金额,我来记录。" : ApplyTic("哦?发工资了?多少多少,快报数,我帮你记收入!", persona, tic);
return (t, null);
}
var text = persona switch
{
"gentle" => "哈哈收到~心情好最重要啦",
"strict" => "已收到。请继续保持记账习惯。",
"meme" => "哈哈哈哈这表情包我先存了",
_ => ApplyTic("卖萌也没用!该省还得省", persona, tic),
};
// 回一个表情包(毒舌回生气,温柔回开心)
var replySticker = persona == "gentle" ? "happy" : "angry";
return (text, replySticker);
}
private async Task<(string Persona, string Tic)> GetPersonaAsync(long userId)
{
var setting = await db.AiCompanionSettings.FirstOrDefaultAsync(s => s.UserId == userId);
var personaKey = setting?.PersonaKey ?? "sassy_cat";
var avatarKey = setting?.AvatarKey ?? "cat";
var tic = await db.AiAvatars.Where(a => a.Key == avatarKey)
.Select(a => a.SpeechTic).FirstOrDefaultAsync() ?? "";
return (personaKey, tic);
}
/// <summary>口癖跟随形象(决策 20);严格管家不用口癖</summary>
private static string ApplyTic(string text, string persona, string tic)
{
if (string.IsNullOrEmpty(tic) || persona == "strict") return text;
return text + tic;
}
}
@@ -0,0 +1,377 @@
using System.Text.Json;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed class BudgetRecommendationService(
AppDbContext db,
ILlmClient llm,
ILogger<BudgetRecommendationService> logger)
{
private static readonly AgentToolDefinition AdjustBudgetTool = new(
"adjust_budget_draft",
"根据用户追加指令调整当前预算草稿。必须返回完整预算草稿,不能直接写入数据库。",
JsonSerializer.Deserialize<JsonElement>(
"""
{
"type": "object",
"properties": {
"suggestedTotal": {
"type": "number",
"minimum": 0
},
"items": {
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"properties": {
"categoryId": { "type": "integer" },
"amount": { "type": "number", "minimum": 0 }
},
"required": ["categoryId", "amount"],
"additionalProperties": false
}
},
"summary": {
"type": "string",
"description": "一句话说明本次调整了什么"
}
},
"required": ["suggestedTotal", "items", "summary"],
"additionalProperties": false
}
"""));
public async Task<BudgetRecommendationResponse> RefineAsync(
long userId,
long ledgerId,
RefineBudgetRecommendationRequest request,
CancellationToken ct)
{
if (!llm.IsEnabled)
throw new InvalidOperationException("AI 预算调整尚未配置");
var instruction = request.Instruction.Trim();
if (instruction.Length == 0)
throw new ArgumentException("请输入预算调整要求");
if (instruction.Length > 500)
throw new ArgumentException("单次调整要求不能超过 500 个字符");
if (request.CurrentDraft.Items.Count > 50)
throw new ArgumentException("预算分类数量过多");
var contexts = await LoadCategoryContextsAsync(
userId,
ledgerId,
request.Year,
request.Month,
ct);
var (currentStart, currentEnd) =
ChinaClock.MonthRangeUtc(request.Year, request.Month);
var currentSpentTotal = await db.Transactions
.Where(transaction =>
transaction.UserId == userId &&
transaction.LedgerId == ledgerId &&
transaction.Type == TransactionType.Expense &&
transaction.OccurredAt >= currentStart &&
transaction.OccurredAt < currentEnd)
.SumAsync(transaction => transaction.Amount, ct);
if (contexts.Count == 0)
throw new ArgumentException("没有可用于预算的支出分类");
var currentItems = request.CurrentDraft.Items
.Where(item => contexts.ContainsKey(item.CategoryId))
.GroupBy(item => item.CategoryId)
.ToDictionary(group => group.Key, group => Math.Max(0, group.Last().Amount));
BudgetRecommendationResponse? validated = null;
string? toolSummary = null;
var response = await llm.RunAgentAsync(
BuildSystemPrompt(request, contexts, currentItems, currentSpentTotal),
instruction,
[AdjustBudgetTool],
(call, _) =>
{
if (call.Name != AdjustBudgetTool.Name)
return Task.FromResult(JsonSerializer.Serialize(new
{
ok = false,
error = "未知预算工具",
}));
try
{
validated = ValidateToolDraft(
request,
contexts,
currentItems,
currentSpentTotal,
call.Arguments,
out toolSummary);
return Task.FromResult(JsonSerializer.Serialize(new
{
ok = true,
draft = new
{
suggestedTotal = validated.SuggestedTotal,
items = validated.Items.Select(item => new
{
categoryId = item.CategoryId,
amount = item.SuggestedAmount,
}),
},
warnings = validated.Warnings,
}));
}
catch (Exception exception) when (
exception is JsonException or ArgumentException)
{
return Task.FromResult(JsonSerializer.Serialize(new
{
ok = false,
error = exception.Message,
}));
}
},
ct: ct);
if (validated is null)
{
logger.LogWarning(
"Budget refinement completed without tool call. user={UserId} ledger={LedgerId}",
userId,
ledgerId);
throw new InvalidOperationException("AI 没有生成可用的预算调整草稿");
}
var summary = string.IsNullOrWhiteSpace(response.Text)
? toolSummary ?? "已根据你的要求调整预算草稿"
: response.Text.Trim();
return validated with { AdjustmentSummary = summary };
}
private async Task<Dictionary<long, CategoryContext>> LoadCategoryContextsAsync(
long userId,
long ledgerId,
int year,
int month,
CancellationToken ct)
{
var (currentStart, currentEnd) = ChinaClock.MonthRangeUtc(year, month);
var historyStart = currentStart.AddMonths(-3);
var categories = await db.Categories
.Where(category =>
!category.IsDeleted &&
category.Type == TransactionType.Expense &&
(category.UserId == null || category.UserId == userId))
.Select(category => new
{
category.Id,
category.Name,
category.IconKey,
category.ColorKey,
})
.ToListAsync(ct);
var transactions = await db.Transactions
.Where(transaction =>
transaction.UserId == userId &&
transaction.LedgerId == ledgerId &&
transaction.Type == TransactionType.Expense &&
transaction.OccurredAt >= historyStart &&
transaction.OccurredAt < currentEnd)
.Select(transaction => new
{
transaction.CategoryId,
transaction.Amount,
transaction.OccurredAt,
})
.ToListAsync(ct);
return categories.ToDictionary(category => category.Id, category =>
{
var currentSpent = transactions
.Where(transaction =>
transaction.CategoryId == category.Id &&
transaction.OccurredAt >= currentStart)
.Sum(transaction => transaction.Amount);
var history = Enumerable.Range(1, 3)
.Select(offset =>
{
var start = currentStart.AddMonths(-offset);
var end = start.AddMonths(1);
return transactions
.Where(transaction =>
transaction.CategoryId == category.Id &&
transaction.OccurredAt >= start &&
transaction.OccurredAt < end)
.Sum(transaction => transaction.Amount);
})
.Where(amount => amount > 0)
.ToList();
return new CategoryContext(
category.Id,
category.Name,
category.IconKey,
category.ColorKey,
currentSpent,
history.Count == 0 ? 0 : history.Average(),
history.Count >= 3 ? "high" : history.Count > 0 ? "medium" : "low");
});
}
private static string BuildSystemPrompt(
RefineBudgetRecommendationRequest request,
IReadOnlyDictionary<long, CategoryContext> contexts,
IReadOnlyDictionary<long, decimal> currentItems,
decimal currentSpentTotal)
{
var history = (request.History ?? [])
.Where(turn => turn.Role is "user" or "assistant")
.TakeLast(12)
.Select(turn => new
{
role = turn.Role,
content = turn.Content.Length <= 500
? turn.Content
: turn.Content[..500],
});
var payload = new
{
year = request.Year,
month = request.Month,
currentDraft = new
{
suggestedTotal = request.CurrentDraft.SuggestedTotal,
items = currentItems.Select(item => new
{
categoryId = item.Key,
amount = item.Value,
}),
},
allowedCategories = contexts.Values.Select(context => new
{
categoryId = context.Id,
name = context.Name,
currentSpent = context.CurrentSpent,
historicalAverage = context.HistoricalAverage,
}),
currentSpentTotal,
recentConversation = history,
};
return """
稿 Agent adjust_budget_draft
稿使 allowedCategories categoryId
currentSpent
稿 summary
""" + JsonSerializer.Serialize(payload);
}
internal static BudgetRecommendationResponse ValidateToolDraft(
RefineBudgetRecommendationRequest request,
IReadOnlyDictionary<long, CategoryContext> contexts,
IReadOnlyDictionary<long, decimal> currentItems,
decimal currentSpentTotal,
string arguments,
out string summary)
{
using var document = JsonDocument.Parse(arguments);
var root = document.RootElement;
if (!root.TryGetProperty("items", out var itemsNode) ||
itemsNode.ValueKind != JsonValueKind.Array)
throw new ArgumentException("AI 返回的预算分类无效");
summary = root.TryGetProperty("summary", out var summaryNode)
? summaryNode.GetString()?.Trim() ?? ""
: "";
var warnings = new List<string>();
var proposed = new Dictionary<long, decimal>();
foreach (var item in itemsNode.EnumerateArray())
{
if (!item.TryGetProperty("categoryId", out var categoryNode) ||
!categoryNode.TryGetInt64(out var categoryId) ||
!contexts.TryGetValue(categoryId, out var context))
{
warnings.Add("已忽略不存在或不可用的分类");
continue;
}
if (!item.TryGetProperty("amount", out var amountNode) ||
!amountNode.TryGetDecimal(out var amount))
{
warnings.Add($"“{context.Name}”金额无效,已保留原值");
continue;
}
amount = Math.Max(0, decimal.Round(amount, 2));
if (amount < context.CurrentSpent)
{
amount = context.CurrentSpent;
warnings.Add($"“{context.Name}”不能低于已花 ¥{context.CurrentSpent:0.##}");
}
proposed[categoryId] = amount;
}
foreach (var current in currentItems)
{
if (!proposed.ContainsKey(current.Key))
{
proposed[current.Key] = current.Value;
warnings.Add($"“{contexts[current.Key].Name}”未明确调整,已保留原值");
}
}
if (proposed.Count == 0)
throw new ArgumentException("AI 没有返回可用的分类预算");
var requestedTotal =
root.TryGetProperty("suggestedTotal", out var totalNode) &&
totalNode.TryGetDecimal(out var parsedTotal)
? Math.Max(0, decimal.Round(parsedTotal, 2))
: Math.Max(0, request.CurrentDraft.SuggestedTotal);
var categoryTotal = proposed.Values.Sum();
var minimumTotal = Math.Max(categoryTotal, currentSpentTotal);
if (requestedTotal < minimumTotal)
{
requestedTotal = minimumTotal;
warnings.Add($"总预算不能低于分类合计或本月已花,已调整为 ¥{minimumTotal:0.##}");
}
var recommendationItems = proposed
.Where(item => item.Value > 0)
.Select(item =>
{
var context = contexts[item.Key];
return new BudgetRecommendationItemDto(
context.Id,
context.Name,
context.IconKey,
item.Value,
context.CurrentSpent,
context.HistoricalAverage,
context.Confidence,
context.ColorKey);
})
.OrderByDescending(item => item.SuggestedAmount)
.ToList();
return new BudgetRecommendationResponse(
request.Year,
request.Month,
requestedTotal,
"预算草稿已调整,确认应用前不会写入正式预算。",
recommendationItems,
summary,
warnings.Distinct().ToList());
}
internal sealed record CategoryContext(
long Id,
string Name,
string IconKey,
string ColorKey,
decimal CurrentSpent,
decimal HistoricalAverage,
string Confidence);
}
@@ -0,0 +1,49 @@
namespace MiaoJiZhang.Api.Services;
public static class ChinaClock
{
private static readonly Lazy<TimeZoneInfo> Zone = new(() =>
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
}
catch (TimeZoneNotFoundException)
{
return TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
}
});
public static DateTime Now => ToLocal(DateTime.UtcNow);
public static DateTime ToLocal(DateTime utc) =>
TimeZoneInfo.ConvertTimeFromUtc(
DateTime.SpecifyKind(utc, DateTimeKind.Utc),
Zone.Value);
public static DateTime ToUtc(DateTime local) =>
TimeZoneInfo.ConvertTimeToUtc(
DateTime.SpecifyKind(local, DateTimeKind.Unspecified),
Zone.Value);
public static (DateTime Start, DateTime End) WeekRangeUtc(DateTime localDate)
{
var date = DateTime.SpecifyKind(localDate.Date, DateTimeKind.Unspecified);
var offset = ((int)date.DayOfWeek + 6) % 7;
var start = date.AddDays(-offset);
return (ToUtc(start), ToUtc(start.AddDays(7)));
}
public static (DateTime Start, DateTime End) MonthRangeUtc(int year, int month)
{
var start = new DateTime(year, month, 1);
return (ToUtc(start), ToUtc(start.AddMonths(1)));
}
public static (DateTime Start, DateTime End) YearRangeUtc(int year)
{
var start = new DateTime(year, 1, 1);
return (ToUtc(start), ToUtc(start.AddYears(1)));
}
}
@@ -0,0 +1,29 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using MiaoJiZhang.Domain.Entities;
using Microsoft.IdentityModel.Tokens;
namespace MiaoJiZhang.Api.Services;
public class JwtService(IConfiguration config)
{
public (string Token, DateTime ExpiresAt) Issue(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Secret"]!));
var expires = DateTime.UtcNow.AddDays(int.Parse(config["Jwt:ExpireDays"] ?? "30"));
var token = new JwtSecurityToken(
issuer: config["Jwt:Issuer"],
audience: config["Jwt:Audience"],
claims:
[
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, user.Username),
new Claim("auth_version", user.AuthVersion.ToString()),
],
expires: expires,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return (new JwtSecurityTokenHandler().WriteToken(token), expires);
}
}
@@ -0,0 +1,26 @@
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public class LedgerResolver(AppDbContext db)
{
public async Task<long?> ResolveAsync(
long userId,
long? requestedLedgerId,
CancellationToken ct = default)
{
if (requestedLedgerId.HasValue)
{
return await db.Ledgers
.Where(l => l.Id == requestedLedgerId.Value && l.OwnerId == userId)
.Select(l => (long?)l.Id)
.FirstOrDefaultAsync(ct);
}
return await db.Ledgers
.Where(l => l.OwnerId == userId && l.IsDefault)
.Select(l => (long?)l.Id)
.FirstOrDefaultAsync(ct);
}
}
@@ -0,0 +1,96 @@
namespace MiaoJiZhang.Api.Services;
public interface ILlmClient
{
bool IsEnabled { get; }
Task<IntentResult?> TryParseIntentAsync(string userText, CancellationToken ct = default);
Task<string?> TryGenerateReplyAsync(string systemPrompt, string userText, CancellationToken ct = default);
Task<IReadOnlyList<ImageParseResult>?> AnalyzeImageAsync(
byte[] imageBytes,
string mimeType,
CancellationToken ct = default);
Task<(bool Ok, string? Error)> TestConnectionAsync(CancellationToken ct = default);
Task<AgentRunResponse> RunAgentAsync(
string systemPrompt,
string userText,
IReadOnlyList<AgentToolDefinition> tools,
Func<AgentToolCall, CancellationToken, Task<string>> executeTool,
Func<string, CancellationToken, Task>? onToken = null,
Func<string, CancellationToken, Task>? onToolRunning = null,
CancellationToken ct = default);
/// <summary>流式生成回复。将 LLM 返回的 token 逐个写入 writer,完成时返回。</summary>
Task StreamReplyAsync(
string systemPrompt,
string userText,
Func<string, CancellationToken, Task> onToken,
CancellationToken ct = default);
}
public record AgentToolDefinition(
string Name,
string Description,
object Parameters);
public record AgentToolCall(
string CallId,
string Name,
string Arguments);
public record AgentRunResponse(
string Text,
int ToolCallCount);
public record ImageParseResult(
string Type,
decimal Amount,
string CategoryName,
string? PaymentMethod,
string Note,
DateTime? OccurredAt);
public class NullLlmClient : ILlmClient
{
public bool IsEnabled => false;
public Task<IntentResult?> TryParseIntentAsync(
string userText,
CancellationToken ct = default) =>
Task.FromResult<IntentResult?>(null);
public Task<string?> TryGenerateReplyAsync(
string systemPrompt,
string userText,
CancellationToken ct = default) =>
Task.FromResult<string?>(null);
public Task<IReadOnlyList<ImageParseResult>?> AnalyzeImageAsync(
byte[] imageBytes,
string mimeType,
CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ImageParseResult>?>(null);
public Task<(bool Ok, string? Error)> TestConnectionAsync(
CancellationToken ct = default) =>
Task.FromResult<(bool, string?)>((false, "API Key 为空"));
public Task<AgentRunResponse> RunAgentAsync(
string systemPrompt,
string userText,
IReadOnlyList<AgentToolDefinition> tools,
Func<AgentToolCall, CancellationToken, Task<string>> executeTool,
Func<string, CancellationToken, Task>? onToken = null,
Func<string, CancellationToken, Task>? onToolRunning = null,
CancellationToken ct = default) =>
Task.FromResult(new AgentRunResponse("", 0));
public async Task StreamReplyAsync(
string systemPrompt,
string userText,
Func<string, CancellationToken, Task> onToken,
CancellationToken ct = default)
{
await Task.CompletedTask;
}
}
@@ -0,0 +1,338 @@
using System.Text;
using System.Text.Json;
namespace MiaoJiZhang.Api.Services;
public partial class OpenAiVisionClient
{
public async Task<AgentRunResponse> RunAgentAsync(
string systemPrompt,
string userText,
IReadOnlyList<AgentToolDefinition> tools,
Func<AgentToolCall, CancellationToken, Task<string>> executeTool,
Func<string, CancellationToken, Task>? onToken = null,
Func<string, CancellationToken, Task>? onToolRunning = null,
CancellationToken ct = default)
{
Load();
if (string.IsNullOrWhiteSpace(_apiKey))
throw new InvalidOperationException("AI 功能尚未配置");
if (_protocol != "responses")
throw new InvalidOperationException("AI Agent 记账要求使用 Responses 协议");
var providerTools = tools.Select(tool => new
{
type = "function",
name = tool.Name,
description = tool.Description,
parameters = tool.Parameters,
}).ToArray();
var input = new object[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userText },
};
string? previousResponseId = null;
IReadOnlyList<object>? toolOutputs = null;
var text = new StringBuilder();
var toolCallCount = 0;
for (var round = 0; round < 4; round++)
{
object body = previousResponseId is null
? new
{
model = _model,
input,
tools = providerTools,
tool_choice = "auto",
max_output_tokens = _maxTokens,
temperature = 0.3,
thinking = new { type = "disabled" },
store = true,
stream = true,
}
: new
{
model = _model,
previous_response_id = previousResponseId,
input = toolOutputs,
tools = providerTools,
tool_choice = "auto",
max_output_tokens = _maxTokens,
temperature = 0.3,
thinking = new { type = "disabled" },
store = true,
stream = true,
};
var turn = await StreamAgentTurnAsync(
body,
async (token, tokenCt) =>
{
text.Append(token);
if (onToken is not null) await onToken(token, tokenCt);
},
ct);
previousResponseId = turn.ResponseId;
if (turn.Calls.Count == 0)
return new AgentRunResponse(text.ToString().Trim(), toolCallCount);
if (string.IsNullOrWhiteSpace(previousResponseId))
throw new InvalidOperationException("AI 工具调用缺少响应 ID");
var outputs = new List<object>();
foreach (var call in turn.Calls)
{
toolCallCount++;
if (toolCallCount > 12)
throw new InvalidOperationException("AI 工具调用次数过多,请重新描述需求");
if (onToolRunning is not null)
await onToolRunning(call.Name, ct);
string output;
try
{
output = await executeTool(call, ct);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Agent tool failed. tool={Tool} callId={CallId}",
call.Name,
call.CallId);
output = JsonSerializer.Serialize(new
{
ok = false,
error = ex.Message,
});
}
outputs.Add(new
{
type = "function_call_output",
call_id = call.CallId,
output,
});
}
toolOutputs = outputs;
}
throw new InvalidOperationException("AI 工具调用轮次过多,请重新描述需求");
}
private async Task<AgentProviderTurn> StreamAgentTurnAsync(
object body,
Func<string, CancellationToken, Task> onToken,
CancellationToken ct)
{
var request = new HttpRequestMessage(
HttpMethod.Post,
_baseUrl + "/responses")
{
Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8,
"application/json"),
};
request.Headers.Add("Authorization", "Bearer " + _apiKey);
using var response = await _http.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
ct);
if (!response.IsSuccessStatusCode)
{
var detail = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException(
$"AI Agent 请求失败 ({(int)response.StatusCode}){ReadAgentError(detail)}");
}
using var stream = await response.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
var calls = new Dictionary<string, AgentCallBuilder>();
string? responseId = null;
var emittedText = false;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (string.IsNullOrWhiteSpace(line) || line == "data: [DONE]") continue;
if (!line.StartsWith("data:", StringComparison.Ordinal)) continue;
var payload = line[5..].TrimStart();
if (payload.Length == 0 || payload == "[DONE]") continue;
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
var eventType = TryGetProperty(root, "type", out var typeNode)
? typeNode.GetString()
: null;
if (eventType is "response.failed" or "error")
throw new InvalidOperationException(ReadAgentError(payload));
if (TryGetProperty(root, "response", out var responseNode) &&
responseNode.ValueKind == JsonValueKind.Object &&
TryGetProperty(responseNode, "id", out var responseIdNode))
{
responseId = responseIdNode.GetString() ?? responseId;
}
if (eventType == "response.output_text.delta" &&
TryGetProperty(root, "delta", out var deltaNode) &&
deltaNode.ValueKind == JsonValueKind.String)
{
var token = deltaNode.GetString();
if (!string.IsNullOrEmpty(token))
{
emittedText = true;
await onToken(token, ct);
}
continue;
}
if (eventType == "response.output_item.added" &&
TryGetProperty(root, "item", out var itemNode))
{
AddFunctionCall(itemNode, calls);
continue;
}
if (eventType == "response.function_call_arguments.delta")
{
var key = ReadCallKey(root);
if (key is not null && calls.TryGetValue(key, out var builder) &&
TryGetProperty(root, "delta", out var argumentDelta))
{
builder.Arguments.Append(argumentDelta.GetString());
}
continue;
}
if (eventType == "response.function_call_arguments.done")
{
var key = ReadCallKey(root);
if (key is not null && calls.TryGetValue(key, out var builder) &&
TryGetProperty(root, "arguments", out var argumentsNode))
{
builder.Arguments.Clear();
builder.Arguments.Append(argumentsNode.GetString());
}
continue;
}
if (eventType == "response.completed" &&
TryGetProperty(root, "response", out var completedResponse))
{
AddFunctionCallsFromResponse(completedResponse, calls);
if (!emittedText)
{
var completeText = ExtractResponseText(completedResponse);
if (!string.IsNullOrWhiteSpace(completeText))
{
emittedText = true;
await onToken(completeText, ct);
}
}
}
}
return new AgentProviderTurn(
responseId,
calls.Values
.Where(call => !string.IsNullOrWhiteSpace(call.CallId) &&
!string.IsNullOrWhiteSpace(call.Name))
.Select(call => new AgentToolCall(
call.CallId,
call.Name,
call.Arguments.ToString()))
.ToList());
}
private static void AddFunctionCallsFromResponse(
JsonElement response,
Dictionary<string, AgentCallBuilder> calls)
{
if (!TryGetProperty(response, "output", out var output) ||
output.ValueKind != JsonValueKind.Array) return;
foreach (var item in output.EnumerateArray()) AddFunctionCall(item, calls);
}
private static void AddFunctionCall(
JsonElement item,
Dictionary<string, AgentCallBuilder> calls)
{
if (!TryGetProperty(item, "type", out var type) ||
type.GetString() != "function_call") return;
var itemId = TryGetProperty(item, "id", out var idNode)
? idNode.GetString()
: null;
var callId = TryGetProperty(item, "call_id", out var callIdNode)
? callIdNode.GetString()
: null;
var name = TryGetProperty(item, "name", out var nameNode)
? nameNode.GetString()
: null;
var key = itemId ?? callId;
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(callId)) return;
if (!calls.TryGetValue(key, out var builder))
{
builder = new AgentCallBuilder(callId!, name ?? "");
calls[key] = builder;
}
if (!string.IsNullOrWhiteSpace(name)) builder.Name = name!;
if (TryGetProperty(item, "arguments", out var arguments) &&
arguments.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(arguments.GetString()))
{
builder.Arguments.Clear();
builder.Arguments.Append(arguments.GetString());
}
}
private static string? ReadCallKey(JsonElement root)
{
if (TryGetProperty(root, "item_id", out var itemId))
return itemId.GetString();
if (TryGetProperty(root, "call_id", out var callId))
return callId.GetString();
return null;
}
private static string ReadAgentError(string raw)
{
try
{
using var document = JsonDocument.Parse(raw);
var root = document.RootElement;
if (TryGetProperty(root, "error", out var error))
{
if (error.ValueKind == JsonValueKind.String)
return error.GetString() ?? "AI 服务返回错误";
if (TryGetProperty(error, "message", out var message))
return message.GetString() ?? error.ToString();
return error.ToString();
}
if (TryGetProperty(root, "message", out var directMessage))
return directMessage.GetString() ?? "AI 服务返回错误";
}
catch (JsonException)
{
}
return raw.Length > 300 ? raw[..300] : raw;
}
private sealed record AgentProviderTurn(
string? ResponseId,
IReadOnlyList<AgentToolCall> Calls);
private sealed class AgentCallBuilder(string callId, string name)
{
public string CallId { get; } = callId;
public string Name { get; set; } = name;
public StringBuilder Arguments { get; } = new();
}
}
@@ -0,0 +1,812 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace MiaoJiZhang.Api.Services;
public partial class OpenAiVisionClient : ILlmClient
{
private readonly IServiceScopeFactory _sf;
private readonly HttpClient _http;
private readonly ILogger<OpenAiVisionClient> _logger;
private string? _baseUrl, _apiKey, _model, _protocol;
private int _maxTokens = 1024;
private DateTime _last = DateTime.MinValue;
private static readonly object _lk = new();
public OpenAiVisionClient(
IServiceScopeFactory sf,
IHttpClientFactory hf,
ILogger<OpenAiVisionClient> logger)
{
_sf = sf;
_http = hf.CreateClient("LlmClient");
_http.Timeout = TimeSpan.FromSeconds(120);
_logger = logger;
}
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
public async Task<IntentResult?> TryParseIntentAsync(
string userText,
CancellationToken ct = default)
{
if (!IsEnabled) return null;
const string prompt =
"你是记账意图识别器。只返回 JSON:" +
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"," +
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
"收入信号包括赚了、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款;" +
"支出分类:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他;" +
"收入分类:工资/奖金/理财/兼职/红包/报销/其他。" +
"查询句不得返回 bill,未明确表示收入的金额默认 expense。";
var response = await L(prompt, userText, ct);
return response is null ? null : PI(response);
}
public async Task<string?> TryGenerateReplyAsync(string s, string u, CancellationToken ct = default)
{ if (!IsEnabled) return null; return await L(s, u, ct); }
public async Task<IReadOnlyList<ImageParseResult>?> AnalyzeImageAsync(
byte[] img,
string mime,
CancellationToken ct = default)
{
if (!IsEnabled) return null;
var b64 = Convert.ToBase64String(img);
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
///////////
//////
note
{{shanghaiNow:yyyy-MM-dd HH:mm:ss}}occurredAt +08:00 ISO 8601
occurredAt null
{"bills":[]} 20
""";
var messages = new List<object>();
if (_protocol == "messages")
{
messages.Add(new
{
role = "user",
content = new object[]
{
new
{
type = "image",
source = new { type = "base64", media_type = mime, data = b64 },
},
new { type = "text", text = systemPrompt },
},
});
}
else if (_protocol == "responses")
{
messages.Add(new { role = "system", content = systemPrompt });
messages.Add(new
{
role = "user",
content = new object[]
{
new { type = "input_image", image_url = "data:" + mime + ";base64," + b64 },
new { type = "input_text", text = "提取截图中的全部独立交易" },
},
});
}
else
{
messages.Add(new { role = "system", content = systemPrompt });
messages.Add(new
{
role = "user",
content = new object[]
{
new
{
type = "image_url",
image_url = new { url = "data:" + mime + ";base64," + b64 },
},
new { type = "text", text = "提取截图中的全部独立交易" },
},
});
}
var diagnosticId = Guid.NewGuid().ToString("N")[..12];
var (json, error) = await CA(BuildBody(messages, 1536, 0.1), ct);
if (json is null)
{
_logger.LogWarning(
"Image model request failed. model={Model} protocol={Protocol} requestId={RequestId} error={Error}",
_model,
_protocol,
diagnosticId,
error);
throw new InvalidOperationException(error ?? "图片模型未返回内容");
}
var providerRequestId = ReadProviderRequestId(json) ?? diagnosticId;
var output = EX(json);
try
{
var results = ParseImageResults(output);
_logger.LogInformation(
"Image model parse completed. model={Model} protocol={Protocol} requestId={RequestId} items={Count}",
_model,
_protocol,
providerRequestId,
results.Count);
return results;
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
_logger.LogWarning(
ex,
"Image model parse failed. model={Model} protocol={Protocol} requestId={RequestId} output={Output}",
_model,
_protocol,
providerRequestId,
SanitizeOutputSnippet(output));
throw new InvalidOperationException("图片模型返回格式无法解析,请重试", ex);
}
}
private static IReadOnlyList<ImageParseResult> ParseImageResults(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("模型没有返回可解析内容");
InvalidOperationException? shapeError = null;
foreach (var candidate in EnumerateJsonCandidates(raw))
{
try
{
using var document = JsonDocument.Parse(
candidate,
new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
});
var results = new List<ImageParseResult>();
if (TryReadImageRoot(document.RootElement, results))
return results;
shapeError = new InvalidOperationException("JSON 中没有账单字段");
}
catch (JsonException)
{
// Keep scanning: models often add prose or multiple fenced blocks.
}
}
if (LooksLikeNoBillReply(raw)) return [];
throw shapeError ?? new InvalidOperationException("未找到完整 JSON");
}
private static IEnumerable<string> EnumerateJsonCandidates(string raw)
{
for (var start = 0; start < raw.Length; start++)
{
var opening = raw[start];
if (opening is not ('{' or '[')) continue;
var stack = new Stack<char>();
var inString = false;
var escaped = false;
for (var index = start; index < raw.Length; index++)
{
var current = raw[index];
if (inString)
{
if (escaped)
{
escaped = false;
}
else if (current == '\\')
{
escaped = true;
}
else if (current == '"')
{
inString = false;
}
continue;
}
if (current == '"')
{
inString = true;
continue;
}
if (current is '{' or '[')
{
stack.Push(current);
continue;
}
if (current is not ('}' or ']')) continue;
if (
stack.Count == 0 ||
(current == '}' && stack.Peek() != '{') ||
(current == ']' && stack.Peek() != '[')
) {
break;
}
stack.Pop();
if (stack.Count == 0)
{
yield return raw[start..(index + 1)];
break;
}
}
}
}
private static bool TryReadImageRoot(
JsonElement root,
List<ImageParseResult> results)
{
if (root.ValueKind == JsonValueKind.Array)
{
foreach (var item in root.EnumerateArray()) AddBill(item, results);
return true;
}
if (root.ValueKind != JsonValueKind.Object) return false;
foreach (var name in new[] { "bills", "items", "transactions", "data" })
{
if (!TryGetProperty(root, name, out var items)) continue;
if (items.ValueKind == JsonValueKind.Array)
{
foreach (var item in items.EnumerateArray()) AddBill(item, results);
return true;
}
if (items.ValueKind == JsonValueKind.Object)
return TryReadImageRoot(items, results);
if (items.ValueKind == JsonValueKind.Null) return true;
}
if (TryGetProperty(root, "result", out var nested))
return TryReadImageRoot(nested, results);
if (
TryGetProperty(root, "matched", out var matched) &&
matched.ValueKind == JsonValueKind.False
) {
return true;
}
if (TryGetProperty(root, "amount", out _))
{
AddBill(root, results);
return true;
}
return false;
}
private static void AddBill(
JsonElement item,
List<ImageParseResult> results)
{
if (results.Count >= 20 || item.ValueKind != JsonValueKind.Object) return;
var amount = ReadAmount(item);
if (amount <= 0) return;
var rawType = ReadText(item, "type", "transactionType", "direction");
var type = rawType?.Trim().ToLowerInvariant() switch
{
"income" or "收入" or "入账" => "income",
"expense" or "支出" or "出账" => "expense",
_ => null,
};
if (type is null) return;
var category = ReadText(
item,
"categoryName",
"category_name",
"category");
var payment = ReadText(
item,
"paymentMethod",
"payment_method",
"payment",
"payMethod");
var note = ReadText(
item,
"note",
"merchant",
"description",
"title",
"counterparty");
var occurredAt = ReadOccurredAt(item);
results.Add(new ImageParseResult(
type,
amount,
string.IsNullOrWhiteSpace(category) ? "其他" : category.Trim(),
string.IsNullOrWhiteSpace(payment) ? null : payment.Trim(),
string.IsNullOrWhiteSpace(note) ? "" : note.Trim(),
occurredAt));
}
private static DateTime? ReadOccurredAt(JsonElement item)
{
var text = ReadText(
item,
"occurredAt",
"occurred_at",
"transactionTime",
"transaction_time",
"dateTime");
if (string.IsNullOrWhiteSpace(text)) return null;
// Reject date-only values so the client can deliberately fall back to now.
if (!Regex.IsMatch(text, @"\b\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}") ||
!Regex.IsMatch(text, @"\b\d{1,2}:\d{2}\b"))
return null;
if (DateTimeOffset.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AllowWhiteSpaces,
out var offsetValue))
{
var utc = offsetValue.UtcDateTime;
return utc <= DateTime.UtcNow.AddMinutes(10) ? utc : null;
}
if (!DateTime.TryParse(
text,
CultureInfo.GetCultureInfo("zh-CN"),
DateTimeStyles.AllowWhiteSpaces,
out var localValue))
return null;
var localUtc = ChinaClock.ToUtc(localValue);
return localUtc <= DateTime.UtcNow.AddMinutes(10) ? localUtc : null;
}
private static decimal ReadAmount(JsonElement item)
{
JsonElement amount = default;
var found =
TryGetProperty(item, "amount", out amount) ||
TryGetProperty(item, "money", out amount) ||
TryGetProperty(item, "price", out amount);
if (!found) return 0;
if (
amount.ValueKind == JsonValueKind.Number &&
amount.TryGetDecimal(out var number)
) {
return number;
}
if (amount.ValueKind != JsonValueKind.String) return 0;
var text = amount.GetString()?.Replace(",", "");
if (string.IsNullOrWhiteSpace(text)) return 0;
var match = Regex.Match(text, @"-?\d+(?:\.\d+)?");
return match.Success &&
decimal.TryParse(
match.Value,
NumberStyles.Number,
CultureInfo.InvariantCulture,
out var parsed)
? parsed
: 0;
}
private static string? ReadText(JsonElement item, params string[] names)
{
foreach (var name in names)
{
if (!TryGetProperty(item, name, out var value)) continue;
if (value.ValueKind == JsonValueKind.String) return value.GetString();
if (value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
return value.ToString();
}
return null;
}
private static bool TryGetProperty(
JsonElement element,
string name,
out JsonElement value)
{
foreach (var property in element.EnumerateObject())
{
if (!property.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
continue;
value = property.Value;
return true;
}
value = default;
return false;
}
private static bool LooksLikeNoBillReply(string raw)
{
return raw.Contains("没有账单", StringComparison.OrdinalIgnoreCase) ||
raw.Contains("未发现交易", StringComparison.OrdinalIgnoreCase) ||
raw.Contains("无法确认", StringComparison.OrdinalIgnoreCase) ||
raw.Contains("no transaction", StringComparison.OrdinalIgnoreCase);
}
private static string? ReadProviderRequestId(string json)
{
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
foreach (var name in new[] { "id", "request_id", "requestId" })
{
if (
TryGetProperty(root, name, out var value) &&
value.ValueKind == JsonValueKind.String
) {
return value.GetString();
}
}
}
catch (JsonException)
{
// The normal response parser will report the malformed payload.
}
return null;
}
private static string SanitizeOutputSnippet(string raw)
{
var sanitized = Regex.Replace(
raw,
@"(?i)""(note|merchant|description|counterparty|paymentMethod|payment_method)""\s*:\s*""(?:\\.|[^""])*""",
@"""$1"":""***""");
sanitized = Regex.Replace(sanitized, @"\b\d{4,}\b", "***");
sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim();
return sanitized.Length <= 500 ? sanitized : sanitized[..500] + "…";
}
public async Task<(bool, string?)> TestConnectionAsync(CancellationToken ct = default)
{
Load();
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
if (_protocol != "responses")
return (false, "AI Agent 记账要求使用 Responses 协议");
try
{
var tool = new AgentToolDefinition(
"diagnostic_echo",
"连接测试时必须调用的无副作用工具",
JsonSerializer.Deserialize<JsonElement>(
"""{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}"""));
var response = await RunAgentAsync(
"你正在执行连接测试。必须调用 diagnostic_echo,参数 value 填 ok。",
"执行工具调用测试",
[tool],
(_, _) => Task.FromResult("{\"ok\":true}"),
ct: ct);
return response.ToolCallCount > 0
? (true, null)
: (false, "模型返回了文本,但没有触发 Function Calling");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
public async Task StreamReplyAsync(string s, string u, Func<string, CancellationToken, Task> onToken, CancellationToken ct = default)
{
if (!IsEnabled) return;
Load();
var msgs = new List<object>();
if (_protocol == "messages") msgs.Add(new { role = "user", content = s + "\n\n" + u });
else { msgs.Add(new { role = "system", content = s }); msgs.Add(new { role = "user", content = u }); }
await SA(BuildBody(msgs, _maxTokens, 0.7), onToken, ct);
}
void Load()
{
if ((DateTime.UtcNow - _last).TotalMinutes < 5) return;
lock (_lk)
{
if ((DateTime.UtcNow - _last).TotalMinutes < 5) return;
try
{
using var scope = _sf.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
_baseUrl = (
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
config.GetValueOrDefault("llm.base_url", "https://api.openai.com/v1") ??
"").TrimEnd('/');
_model = Environment.GetEnvironmentVariable("LLM_MODEL") ??
config.GetValueOrDefault("llm.model", "gpt-4o-mini");
_protocol = Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
config.GetValueOrDefault("llm.protocol", "chat_completions");
_maxTokens = int.TryParse(
config.GetValueOrDefault("llm.max_tokens"),
out var maxTokens)
? Math.Clamp(maxTokens, 64, 4096)
: 1024;
_last = DateTime.UtcNow;
}
catch (Exception exception)
{
_logger.LogError(exception, "Failed to load LLM configuration");
}
}
}
async Task<string?> L(string sys, string user, CancellationToken ct)
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, 0.7), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
object BuildBody(List<object> msgs, int maxT, double temp) => _protocol switch
{ "messages" => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp }, "responses" => new { model = _model, input = msgs, max_output_tokens = maxT, temperature = temp, thinking = new { type = "disabled" } }, _ => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp } };
async Task<(string?, string?)> CA(object body, CancellationToken ct)
{
Load();
try
{
var ep = _protocol switch { "messages" => "/messages", "responses" => "/responses", _ => "/chat/completions" };
var json = JsonSerializer.Serialize(body);
var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + ep) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
if (_protocol == "messages") { req.Headers.Add("x-api-key", _apiKey); req.Headers.Add("anthropic-version", "2023-06-01"); }
else req.Headers.Add("Authorization", "Bearer " + _apiKey);
var r = await _http.SendAsync(req, ct); var t = await r.Content.ReadAsStringAsync(ct);
if (r.IsSuccessStatusCode) return (t, null);
var detail = "";
try { var e = JsonDocument.Parse(t).RootElement; if (e.TryGetProperty("error", out var er)) { string? em = null; if (er.TryGetProperty("message", out var m)) em = m.GetString(); detail = ": " + (em ?? er.ToString()); } } catch { }
var msg = ((int)r.StatusCode) switch { 401 => "401 认证失败", 403 => "403 禁止访问", 404 => "404 接口不存在", 429 => "429 频率过高", 500 => "500 服务异常", _ => ((int)r.StatusCode).ToString() };
return (null, msg + detail);
}
catch (TaskCanceledException) { return (null, "请求超时"); }
catch (HttpRequestException ex) { return (null, "网络错误: " + (ex.InnerException?.Message ?? ex.Message)); }
catch (Exception ex) { return (null, ex.Message); }
}
async Task SA(object body, Func<string, CancellationToken, Task> onToken, CancellationToken ct)
{
Load();
var ep = _protocol switch { "messages" => "/messages", "responses" => "/responses", _ => "/chat/completions" };
var serialized = JsonSerializer.Serialize(body);
var json = serialized[..^1] + ",\"stream\":true}";
var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + ep) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
if (_protocol == "messages") { req.Headers.Add("x-api-key", _apiKey); req.Headers.Add("anthropic-version", "2023-06-01"); }
else req.Headers.Add("Authorization", "Bearer " + _apiKey);
var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
if (!resp.IsSuccessStatusCode)
throw new HttpRequestException($"LLM 流式请求失败 ({(int)resp.StatusCode})");
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
var emitted = false;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (string.IsNullOrWhiteSpace(line) || line == "data: [DONE]") continue;
var failure = StreamFailure(line);
if (failure is not null) throw new InvalidOperationException(failure);
var tok = ET(line);
if (!string.IsNullOrWhiteSpace(tok)) { emitted = true; await onToken(tok, ct); }
}
if (!emitted) throw new InvalidOperationException("AI 未返回内容");
}
static string? StreamFailure(string line)
{
var i = line.IndexOf('{');
if (i < 0) return null;
try
{
var d = JsonDocument.Parse(line[i..]).RootElement;
if (!d.TryGetProperty("type", out var type)) return null;
var value = type.GetString();
if (value == "response.incomplete")
{
var reason = d.TryGetProperty("response", out var response)
&& response.TryGetProperty("incomplete_details", out var details)
&& details.TryGetProperty("reason", out var reasonElement)
? reasonElement.GetString() : null;
return reason == "length" ? "AI 输出长度不足,请稍后重试" : "AI 回复未完成";
}
if (value is "response.failed" or "error") return "AI 服务返回失败";
return null;
}
catch { return null; }
}
static string? ET(string line)
{
if (!line.StartsWith("data: ") && !line.StartsWith("data:")) return null;
var i = line.IndexOf('{'); if (i < 0) return null;
try
{
var d = JsonDocument.Parse(line[i..]).RootElement;
if (d.TryGetProperty("choices", out var ch) && ch.ValueKind == JsonValueKind.Array && ch.GetArrayLength() > 0 && ch[0].TryGetProperty("delta", out var dt) && dt.TryGetProperty("content", out var ct)) return ct.GetString();
if (d.TryGetProperty("type", out var t))
{ var ty = t.GetString(); if ((ty == "response.output_text.delta" || ty == "content_block_delta") && d.TryGetProperty("delta", out var dl)) { if (dl.ValueKind == JsonValueKind.String) return dl.GetString(); if (dl.TryGetProperty("text", out var tx)) return tx.GetString(); } }
return null;
}
catch { return null; }
}
string EX(string json)
{
try
{
using var document = JsonDocument.Parse(json);
return ExtractResponseText(document.RootElement);
}
catch (JsonException)
{
return "";
}
}
private static string ExtractResponseText(JsonElement root)
{
if (
TryGetProperty(root, "output_text", out var directOutput) &&
directOutput.ValueKind == JsonValueKind.String
) {
return directOutput.GetString() ?? "";
}
if (TryGetProperty(root, "choices", out var choices) &&
choices.ValueKind == JsonValueKind.Array)
{
foreach (var choice in choices.EnumerateArray())
{
if (!TryGetProperty(choice, "message", out var message)) continue;
if (!TryGetProperty(message, "content", out var content)) continue;
var value = ExtractTextParts(content);
if (value.Length > 0) return value;
}
}
if (TryGetProperty(root, "content", out var anthropicContent))
{
var value = ExtractTextParts(anthropicContent);
if (value.Length > 0) return value;
}
if (TryGetProperty(root, "output", out var output) &&
output.ValueKind == JsonValueKind.Array)
{
var messageParts = new List<string>();
var fallbackParts = new List<string>();
foreach (var item in output.EnumerateArray())
{
var type = TryGetProperty(item, "type", out var typeElement)
? typeElement.GetString()
: null;
if (type == "message" &&
TryGetProperty(item, "content", out var messageContent))
{
var value = ExtractTextParts(messageContent);
if (value.Length > 0) messageParts.Add(value);
}
if (TryGetProperty(item, "summary", out var summary))
{
var value = ExtractTextParts(summary);
if (value.Length > 0) fallbackParts.Add(value);
}
}
if (messageParts.Count > 0) return string.Join("", messageParts);
if (fallbackParts.Count > 0) return string.Join("", fallbackParts);
}
if (
TryGetProperty(root, "response", out var response) &&
response.ValueKind == JsonValueKind.Object
) {
return ExtractResponseText(response);
}
return "";
}
private static string ExtractTextParts(JsonElement element)
{
if (element.ValueKind == JsonValueKind.String)
return element.GetString() ?? "";
if (element.ValueKind == JsonValueKind.Array)
{
var parts = new List<string>();
foreach (var item in element.EnumerateArray())
{
var value = ExtractTextParts(item);
if (value.Length > 0) parts.Add(value);
}
return string.Join("", parts);
}
if (element.ValueKind != JsonValueKind.Object) return "";
if (TryGetProperty(element, "text", out var text))
{
if (text.ValueKind == JsonValueKind.String)
return text.GetString() ?? "";
if (
text.ValueKind == JsonValueKind.Object &&
TryGetProperty(text, "value", out var textValue) &&
textValue.ValueKind == JsonValueKind.String
) {
return textValue.GetString() ?? "";
}
}
if (
TryGetProperty(element, "output_text", out var outputText) &&
outputText.ValueKind == JsonValueKind.String
) {
return outputText.GetString() ?? "";
}
if (TryGetProperty(element, "content", out var content))
return ExtractTextParts(content);
return "";
}
static IntentResult? PI(string response)
{
try
{
var json = response.Trim();
var fence = new string((char)96, 3);
if (json.StartsWith(fence))
{
json = json.Replace(fence, "").Trim();
if (json.StartsWith("json", StringComparison.OrdinalIgnoreCase))
json = json[4..].Trim();
}
var document = JsonDocument.Parse(json).RootElement;
var kind = document.TryGetProperty("kind", out var kindNode)
? kindNode.GetString() ?? "chat"
: "chat";
if (kind != "bill") return new IntentResult(kind, null);
var typeText = document.TryGetProperty("type", out var typeNode)
? typeNode.GetString()
: null;
typeText = typeText?.Trim().ToLowerInvariant();
if (typeText is null ||
typeText is not ("income" or "expense"))
return new IntentResult("chat", null);
var type = typeText == "income"
? TransactionType.Income
: TransactionType.Expense;
var category = document.TryGetProperty(
"categoryName",
out var categoryNode)
? categoryNode.GetString() ?? "其他"
: "其他";
var note = document.TryGetProperty("note", out var noteNode)
? noteNode.GetString() ?? category
: category;
var amount = document.TryGetProperty("amount", out var amountNode) &&
amountNode.TryGetDecimal(out var value)
? value
: 0;
return new IntentResult(
"bill",
new ParsedBill(
type,
0,
category,
note,
amount,
null));
}
catch
{
return null;
}
}
}
@@ -0,0 +1,53 @@
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public class RecycleBinCleanupService(
IServiceScopeFactory scopeFactory,
ILogger<RecycleBinCleanupService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Cleanup(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromHours(24));
while (await timer.WaitForNextTickAsync(stoppingToken))
await Cleanup(stoppingToken);
}
private async Task Cleanup(CancellationToken ct)
{
try
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow.AddDays(-30);
var ids = await db.Transactions.IgnoreQueryFilters()
.Where(t => t.IsDeleted && t.DeletedAt < cutoff)
.Select(t => t.Id)
.ToListAsync(ct);
if (ids.Count == 0) return;
await db.ChatMessages
.Where(message =>
message.TransactionId.HasValue &&
ids.Contains(message.TransactionId.Value))
.ExecuteUpdateAsync(setters => setters
.SetProperty(message => message.TransactionId, (long?)null)
.SetProperty(message => message.Content, """{"deleted":true}"""), ct);
await db.Transactions.IgnoreQueryFilters()
.Where(t => ids.Contains(t.Id))
.ExecuteDeleteAsync(ct);
logger.LogInformation(
"Permanently removed {Count} transactions after recycle-bin retention",
ids.Count);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
catch (Exception exception)
{
logger.LogError(exception, "Recycle-bin cleanup failed");
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"Urls": "http://127.0.0.1:3000;http://0.0.0.0:5000",
"ConnectionStrings": {
"Default": ""
},
"Jwt": {
"Secret": "",
"Issuer": "MiaoJiZhang.Api",
"Audience": "MiaoJiZhang.App",
"ExpireDays": "30"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Admin": {
"Key": ""
}
}
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-BV_Zb8mM.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Avatars`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await y.updateAvatar(w.value.id,e):await y.createAvatar(e),c.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await y.deleteAvatar(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-switch`),L=e(`a-form`),R=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[7]||=s(`h2`,null,`AI 形象管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[6]||=m(` 新建形象`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(R,{open:C.value,"onUpdate:open":i[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:n(()=>[o(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`默认名`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`口癖后缀`},{default:n(()=>[o(M,{value:T.value.speechTic,"onUpdate:value":i[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),i[8]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),o(N,{label:`头像图片 URL`},{default:n(()=>[o(M,{value:T.value.imageUrl,"onUpdate:value":i[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(I,{checked:T.value.isEnabled,"onUpdate:checked":i[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-C4vz6nB3.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Avatars`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await y.updateAvatar(w.value.id,e):await y.createAvatar(e),c.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await y.deleteAvatar(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-switch`),L=e(`a-form`),R=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[7]||=s(`h2`,null,`AI 形象管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[6]||=m(` 新建形象`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(R,{open:C.value,"onUpdate:open":i[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:n(()=>[o(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`默认名`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`口癖后缀`},{default:n(()=>[o(M,{value:T.value.speechTic,"onUpdate:value":i[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),i[8]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),o(N,{label:`头像图片 URL`},{default:n(()=>[o(M,{value:T.value.imageUrl,"onUpdate:value":i[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(I,{checked:T.value.isEnabled,"onUpdate:checked":i[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,bn as l,fn as u,gn as d,or as f,vn as p,xn as m,zn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./api-BV_Zb8mM.js";var _={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},y={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},b={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},x={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},S={key:0,style:{color:`#00B386`}},C={key:1,style:{color:`#ccc`}},w={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},T={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},E={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},D=t({__name:`Configs`,setup(t){let D=a([]),O=a(!0),k={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},A=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],j=d(()=>{let e={};for(let t of D.value){let n=A.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(M);async function M(){O.value=!0;try{D.value=await g.configs()}finally{O.value=!1}}let N=a(!1),P=a(null),F=a(``),I=d(()=>P.value?k[P.value.key]:null);function L(e){P.value=e,F.value=e.value,N.value=!0}async function R(){P.value&&(await g.updateConfig(P.value.id,F.value),c.success(`已更新 ${P.value.key}`),N.value=!1,M())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),d=e(`a-card`),g=e(`a-col`),D=e(`a-row`),O=e(`a-tab-pane`),z=e(`a-tabs`),B=e(`a-switch`),V=e(`a-input-number`),H=e(`a-select`),U=e(`a-input`),W=e(`a-modal`);return r(),l(`div`,null,[s(`div`,_,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:M},{default:n(()=>[...i[5]||=[m(`刷新`,-1)]]),_:1})]),o(z,null,{default:n(()=>[(r(),l(u,null,h(A,e=>o(O,{key:e.key,tab:e.label},{default:n(()=>[o(D,{gutter:[16,12]},{default:n(()=>[(r(!0),l(u,null,h(j.value[e.key],e=>(r(),p(g,{key:e.id,span:8},{default:n(()=>[o(d,{size:`small`,hoverable:``,onClick:t=>L(e)},{default:n(()=>[s(`div`,v,[s(`div`,null,[s(`div`,y,f(k[e.key]?.label||e.key),1),s(`div`,b,f(k[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[m(`v`+f(e.version),1)]),_:2},1024)]),k[e.key]?.type===`switch`?(r(),l(`div`,x,[e.value===`true`?(r(),l(`span`,S,`✅ 已开启`)):(r(),l(`span`,C,`❌ 已关闭`))])):(r(),l(`div`,w,f(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,T,f(e.updatedAt?.split(`T`)[0]),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(W,{open:N.value,"onUpdate:open":i[4]||=e=>N.value=e,title:`编辑配置: ${P.value?.key}`,onOk:R,width:440},{default:n(()=>[s(`div`,E,f(I.value?.desc),1),I.value?.type===`switch`?(r(),p(B,{key:0,checked:F.value===`true`,onChange:i[0]||=e=>F.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):I.value?.type===`number`?(r(),p(V,{key:1,value:F.value,"onUpdate:value":i[1]||=e=>F.value=e,style:{width:`100%`}},null,8,[`value`])):I.value?.type===`select`&&I.value.options?(r(),p(H,{key:2,value:F.value,"onUpdate:value":i[2]||=e=>F.value=e,style:{width:`100%`},options:I.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),p(U,{key:3,value:F.value,"onUpdate:value":i[3]||=e=>F.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{D as default};
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,bn as l,fn as u,gn as d,or as f,vn as p,xn as m,zn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./api-C4vz6nB3.js";var _={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},y={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},b={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},x={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},S={key:0,style:{color:`#00B386`}},C={key:1,style:{color:`#ccc`}},w={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},T={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},E={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},D=t({__name:`Configs`,setup(t){let D=a([]),O=a(!0),k={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},A=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],j=d(()=>{let e={};for(let t of D.value){let n=A.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(M);async function M(){O.value=!0;try{D.value=await g.configs()}finally{O.value=!1}}let N=a(!1),P=a(null),F=a(``),I=d(()=>P.value?k[P.value.key]:null);function L(e){P.value=e,F.value=e.value,N.value=!0}async function R(){P.value&&(await g.updateConfig(P.value.id,F.value),c.success(`已更新 ${P.value.key}`),N.value=!1,M())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),d=e(`a-card`),g=e(`a-col`),D=e(`a-row`),O=e(`a-tab-pane`),z=e(`a-tabs`),B=e(`a-switch`),V=e(`a-input-number`),H=e(`a-select`),U=e(`a-input`),W=e(`a-modal`);return r(),l(`div`,null,[s(`div`,_,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:M},{default:n(()=>[...i[5]||=[m(`刷新`,-1)]]),_:1})]),o(z,null,{default:n(()=>[(r(),l(u,null,h(A,e=>o(O,{key:e.key,tab:e.label},{default:n(()=>[o(D,{gutter:[16,12]},{default:n(()=>[(r(!0),l(u,null,h(j.value[e.key],e=>(r(),p(g,{key:e.id,span:8},{default:n(()=>[o(d,{size:`small`,hoverable:``,onClick:t=>L(e)},{default:n(()=>[s(`div`,v,[s(`div`,null,[s(`div`,y,f(k[e.key]?.label||e.key),1),s(`div`,b,f(k[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[m(`v`+f(e.version),1)]),_:2},1024)]),k[e.key]?.type===`switch`?(r(),l(`div`,x,[e.value===`true`?(r(),l(`span`,S,`✅ 已开启`)):(r(),l(`span`,C,`❌ 已关闭`))])):(r(),l(`div`,w,f(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,T,f(e.updatedAt?.split(`T`)[0]),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(W,{open:N.value,"onUpdate:open":i[4]||=e=>N.value=e,title:`编辑配置: ${P.value?.key}`,onOk:R,width:440},{default:n(()=>[s(`div`,E,f(I.value?.desc),1),I.value?.type===`switch`?(r(),p(B,{key:0,checked:F.value===`true`,onChange:i[0]||=e=>F.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):I.value?.type===`number`?(r(),p(V,{key:1,value:F.value,"onUpdate:value":i[1]||=e=>F.value=e,style:{width:`100%`}},null,8,[`value`])):I.value?.type===`select`&&I.value.options?(r(),p(H,{key:2,value:F.value,"onUpdate:value":i[2]||=e=>F.value=e,style:{width:`100%`},options:I.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),p(U,{key:3,value:F.value,"onUpdate:value":i[3]||=e=>F.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{D as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`PlusOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){return e(t,s({},s({},n,r.attrs),{icon:o}),null)};l.displayName=`DeleteOutlined`,l.inheritAttrs=!1;var u={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`};function d(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){f(e,t,n[t])})}return e}function f(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var p=function(n,r){return e(t,d({},d({},n,r.attrs),{icon:u}),null)};p.displayName=`EditOutlined`,p.inheritAttrs=!1;export{l as n,a as r,p as t};
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-C4vz6nB3.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Personas`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await y.updatePersona(w.value.id,e):await y.createPersona(e),c.success(w.value?`已更新(版本号+1`:`已创建`),C.value=!1,D()}async function j(e){await y.deletePersona(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-textarea`),L=e(`a-switch`),R=e(`a-form`),z=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[8]||=s(`h2`,null,`AI 性格管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[7]||=m(` 新建性格`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(z,{open:C.value,"onUpdate:open":i[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:n(()=>[o(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`名称`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`描述`},{default:n(()=>[o(M,{value:T.value.description,"onUpdate:value":i[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),o(N,{label:`示例台词`},{default:n(()=>[o(M,{value:T.value.sampleLine,"onUpdate:value":i[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),o(N,{label:`Prompt 模板`},{default:n(()=>[o(I,{value:T.value.promptTemplate,"onUpdate:value":i[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),i[9]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),s(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(L,{checked:T.value.isEnabled,"onUpdate:checked":i[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-BV_Zb8mM.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Personas`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await y.updatePersona(w.value.id,e):await y.createPersona(e),c.success(w.value?`已更新(版本号+1`:`已创建`),C.value=!1,D()}async function j(e){await y.deletePersona(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-textarea`),L=e(`a-switch`),R=e(`a-form`),z=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[8]||=s(`h2`,null,`AI 性格管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[7]||=m(` 新建性格`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(z,{open:C.value,"onUpdate:open":i[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:n(()=>[o(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`名称`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`描述`},{default:n(()=>[o(M,{value:T.value.description,"onUpdate:value":i[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),o(N,{label:`示例台词`},{default:n(()=>[o(M,{value:T.value.sampleLine,"onUpdate:value":i[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),o(N,{label:`Prompt 模板`},{default:n(()=>[o(I,{value:T.value.promptTemplate,"onUpdate:value":i[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),i[9]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),s(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(L,{checked:T.value.isEnabled,"onUpdate:checked":i[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
@@ -0,0 +1 @@
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`ReloadOutlined`,a.inheritAttrs=!1;export{a as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{n as _,r as v,t as y}from"./EditOutlined-CeylGsUo.js";import{t as b}from"./api-C4vz6nB3.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=t({__name:`Stickers`,setup(t){let C=a([]),w=a(!0),T=a(!1),E=a(null),D=a({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(k);async function k(){w.value=!0;try{C.value=await b.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await b.updateSticker(E.value.id,e):await b.createSticker(e),c.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await b.deleteSticker(e),c.success(`已删除`),k()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),b=e(`a-popconfirm`),k=e(`a-table`),P=e(`a-input`),F=e(`a-form-item`),I=e(`a-col`),L=e(`a-row`),R=e(`a-select-option`),z=e(`a-select`),B=e(`a-switch`),V=e(`a-form`),H=e(`a-modal`);return r(),u(d,null,[s(`div`,x,[i[8]||=s(`h2`,null,`表情包库`,-1),o(a,{type:`primary`,onClick:A},{default:n(()=>[o(l(v)),i[7]||=m(` 新建表情包`,-1)]),_:1})]),o(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`tags`?(r(),u(d,{key:0},[t.triggerTags?(r(!0),u(d,{key:0},g((t.triggerTags||``).split(`,`).filter(Boolean),e=>(r(),p(c,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:n(()=>[m(f(e),1)]),_:2},1024))),128)):(r(),u(`span`,S,`-`))],64)):h(``,!0),e.key===`on`?(r(),p(c,{key:1,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:2},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(t)},{default:n(()=>[o(l(y))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(_))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(H,{open:T.value,"onUpdate:open":i[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:n(()=>[o(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`Key`},{default:n(()=>[o(P,{value:D.value.key,"onUpdate:value":i[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`名称`},{default:n(()=>[o(P,{value:D.value.label,"onUpdate:value":i[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`分组`},{default:n(()=>[o(z,{value:D.value.groupKey,"onUpdate:value":i[2]||=e=>D.value.groupKey=e},{default:n(()=>[o(R,{value:`ai_exclusive`},{default:n(()=>[...i[9]||=[m(`🤖 AI 专属`,-1)]]),_:1}),o(R,{value:`classic`},{default:n(()=>[...i[10]||=[m(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`图片 URL`},{default:n(()=>[o(P,{value:D.value.imageUrl,"onUpdate:value":i[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(F,{label:`触发标签`},{default:n(()=>[o(P,{value:D.value.triggerTags,"onUpdate:value":i[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),i[11]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),o(F,{label:`是否启用`},{default:n(()=>[o(B,{checked:D.value.isEnabled,"onUpdate:checked":i[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{n as _,r as v,t as y}from"./EditOutlined-CeylGsUo.js";import{t as b}from"./api-BV_Zb8mM.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=t({__name:`Stickers`,setup(t){let C=a([]),w=a(!0),T=a(!1),E=a(null),D=a({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(k);async function k(){w.value=!0;try{C.value=await b.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await b.updateSticker(E.value.id,e):await b.createSticker(e),c.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await b.deleteSticker(e),c.success(`已删除`),k()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),b=e(`a-popconfirm`),k=e(`a-table`),P=e(`a-input`),F=e(`a-form-item`),I=e(`a-col`),L=e(`a-row`),R=e(`a-select-option`),z=e(`a-select`),B=e(`a-switch`),V=e(`a-form`),H=e(`a-modal`);return r(),u(d,null,[s(`div`,x,[i[8]||=s(`h2`,null,`表情包库`,-1),o(a,{type:`primary`,onClick:A},{default:n(()=>[o(l(v)),i[7]||=m(` 新建表情包`,-1)]),_:1})]),o(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`tags`?(r(),u(d,{key:0},[t.triggerTags?(r(!0),u(d,{key:0},g((t.triggerTags||``).split(`,`).filter(Boolean),e=>(r(),p(c,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:n(()=>[m(f(e),1)]),_:2},1024))),128)):(r(),u(`span`,S,`-`))],64)):h(``,!0),e.key===`on`?(r(),p(c,{key:1,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:2},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(t)},{default:n(()=>[o(l(y))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(_))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(H,{open:T.value,"onUpdate:open":i[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:n(()=>[o(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`Key`},{default:n(()=>[o(P,{value:D.value.key,"onUpdate:value":i[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`名称`},{default:n(()=>[o(P,{value:D.value.label,"onUpdate:value":i[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`分组`},{default:n(()=>[o(z,{value:D.value.groupKey,"onUpdate:value":i[2]||=e=>D.value.groupKey=e},{default:n(()=>[o(R,{value:`ai_exclusive`},{default:n(()=>[...i[9]||=[m(`🤖 AI 专属`,-1)]]),_:1}),o(R,{value:`classic`},{default:n(()=>[...i[10]||=[m(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`图片 URL`},{default:n(()=>[o(P,{value:D.value.imageUrl,"onUpdate:value":i[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(F,{label:`触发标签`},{default:n(()=>[o(P,{value:D.value.triggerTags,"onUpdate:value":i[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),i[11]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),o(F,{label:`是否启用`},{default:n(()=>[o(B,{checked:D.value.isEnabled,"onUpdate:checked":i[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M824.2 699.9a301.55 301.55 0 00-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5a300.95 300.95 0 00-86.4 60.4C345 754.6 314 826.8 312 903.8a8 8 0 008 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5A226.62 226.62 0 01612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c.1 4.3 3.7 7.7 8 7.7h56a8 8 0 008-8.2c-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5a126.86 126.86 0 01-37.5-91.8c.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5A127.3 127.3 0 01612 612zM361.5 510.4c-.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1a127.54 127.54 0 01-38.7-95.4c.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204a8 8 0 008 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z`}}]},name:`team`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`TeamOutlined`,a.inheritAttrs=!1;export{a as t};
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./ReloadOutlined-CVrW_3-b.js";import{t as _}from"./api-C4vz6nB3.js";var v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},y={style:{"margin-bottom":`14px`,display:`flex`,gap:`8px`}},b={key:0},x={key:1,style:{color:`#ccc`}},S={style:{color:`#999`}},C=15,w=t({__name:`Users`,setup(t){let w=a([]),T=a(0),E=a(!0),D=a(``),O=a(1),k=[{title:`ID`,dataIndex:`id`,key:`id`,width:60},{title:`用户名`,dataIndex:`username`,key:`un`,width:120},{title:`模式`,dataIndex:`appMode`,key:`mode`,width:80},{title:`AI 伙伴`,key:`comp`,width:150},{title:`账单(总/AI)`,key:`tx`},{title:`封禁`,dataIndex:`isBanned`,key:`ban`,width:70},{title:`注册时间`,dataIndex:`createdAt`,key:`reg`,width:110},{title:`最后登录`,dataIndex:`lastLoginAt`,key:`login`,width:110},{title:``,key:`act`,width:200}],A=a(!1),j=a(``),M=a(null);i(N);async function N(){E.value=!0;try{let e=await _.users({search:D.value||void 0,page:O.value,limit:C});w.value=e.list,T.value=e.total}finally{E.value=!1}}async function P(){O.value=1,N()}async function F(e){let t=await _.toggleBan(e.id);c.success(t.isBanned?`已封禁 ${e.username}`:`已解封 ${e.username}`),N()}async function I(e){j.value=e.nickname||e.username,A.value=!0,M.value=await _.userStats(e.id)}return(t,i)=>{let a=e(`a-button`),c=e(`a-input-search`),_=e(`a-tag`),L=e(`a-popconfirm`),R=e(`a-table`),z=e(`a-statistic`),B=e(`a-card`),V=e(`a-col`),H=e(`a-row`),U=e(`a-modal`);return r(),u(d,null,[s(`div`,v,[i[3]||=s(`h2`,null,`用户管理`,-1),o(a,{onClick:N},{default:n(()=>[o(l(g)),i[2]||=m(` 刷新`,-1)]),_:1})]),s(`div`,y,[o(c,{value:D.value,"onUpdate:value":i[0]||=e=>D.value=e,placeholder:`搜索用户名...`,style:{"max-width":`280px`},onSearch:P},null,8,[`value`])]),o(R,{columns:k,dataSource:w.value,loading:E.value,rowKey:`id`,size:`small`,pagination:{current:O.value,total:T.value,pageSize:C,showTotal:e=>`${e}`,onChange:e=>{O.value=e,N()}}},{bodyCell:n(({column:e,record:t})=>[e.key===`mode`?(r(),p(_,{key:0,color:t.appMode===`ai`?`blue`:`green`},{default:n(()=>[m(f(t.appMode===`ai`?`全AI`:`普通`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`comp`?(r(),u(d,{key:1},[t.companion?(r(),u(`span`,b,`形象:`+f(t.companion.avatarKey)+` · 性格:`+f(t.companion.personaKey),1)):(r(),u(`span`,x,`未设置`))],64)):h(``,!0),e.key===`tx`?(r(),u(d,{key:2},[m(f(t.txCount)+` `,1),s(`span`,S,`(AI:`+f(t.aiTxCount)+`)`,1)],64)):h(``,!0),e.key===`ban`?(r(),p(_,{key:3,color:t.isBanned?`red`:`default`},{default:n(()=>[m(f(t.isBanned?`已封`:`正常`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`reg`?(r(),u(d,{key:4},[m(f(t.createdAt?.split(`T`)[0]),1)],64)):h(``,!0),e.key===`login`?(r(),u(d,{key:5},[m(f(t.lastLoginAt?t.lastLoginAt.split(`T`)[0]:`从未`),1)],64)):h(``,!0),e.key===`act`?(r(),u(d,{key:6},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>I(t)},{default:n(()=>[...i[4]||=[m(`📊 统计`,-1)]]),_:1},8,[`onClick`]),o(L,{title:t.isBanned?`确定解封?`:`确定封禁?`,onConfirm:e=>F(t)},{default:n(()=>[o(a,{size:`small`,danger:!t.isBanned},{default:n(()=>[m(f(t.isBanned?`解封`:`封禁`),1)]),_:2},1032,[`danger`])]),_:2},1032,[`title`,`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`,`pagination`]),o(U,{open:A.value,"onUpdate:open":i[1]||=e=>A.value=e,title:`${j.value} 使用统计`,footer:null,width:420},{default:n(()=>[M.value?(r(),p(H,{key:0,gutter:12},{default:n(()=>[o(V,{span:8},{default:n(()=>[o(B,{size:`small`},{default:n(()=>[o(z,{title:`总账单`,value:M.value.totalTransactions},null,8,[`value`])]),_:1})]),_:1}),o(V,{span:8},{default:n(()=>[o(B,{size:`small`},{default:n(()=>[o(z,{title:`AI 记账`,value:M.value.aiBooked},null,8,[`value`])]),_:1})]),_:1}),o(V,{span:8},{default:n(()=>[o(B,{size:`small`},{default:n(()=>[o(z,{title:`AI 准确率`,value:M.value.aiAccuracy,suffix:`%`,"value-style":{color:M.value.aiAccuracy>=70?`#00B386`:`#F0642D`}},null,8,[`value`,`value-style`])]),_:1})]),_:1})]),_:1})):h(``,!0)]),_:1},8,[`open`,`title`])],64)}}});export{w as default};
@@ -0,0 +1 @@
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./ReloadOutlined-CVrW_3-b.js";import{t as _}from"./api-BV_Zb8mM.js";var v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},y={style:{"margin-bottom":`14px`,display:`flex`,gap:`8px`}},b={key:0},x={key:1,style:{color:`#ccc`}},S={style:{color:`#999`}},C={key:2,style:{"font-size":`10px`,color:`#999`,"margin-top":`3px`}},w=15,T=t({__name:`Users`,setup(t){let T=a([]),E=a(0),D=a(!0),O=a(``),k=a(1),A=[{title:`ID`,dataIndex:`id`,key:`id`,width:60},{title:`用户名`,dataIndex:`username`,key:`un`,width:120},{title:`模式`,dataIndex:`appMode`,key:`mode`,width:80},{title:`AI 伙伴`,key:`comp`,width:150},{title:`账单(总/AI)`,key:`tx`},{title:`状态`,key:`status`,width:100},{title:`注册时间`,dataIndex:`createdAt`,key:`reg`,width:110},{title:`最后登录`,dataIndex:`lastLoginAt`,key:`login`,width:110},{title:``,key:`act`,width:200}],j=a(!1),M=a(``),N=a(null);i(P);async function P(){D.value=!0;try{let e=await _.users({search:O.value||void 0,page:k.value,limit:w});T.value=e.list,E.value=e.total}finally{D.value=!1}}async function F(){k.value=1,P()}async function I(e){let t=await _.toggleBan(e.id);c.success(t.isBanned?`已封禁 ${e.username}`:`已解封 ${e.username}`),P()}async function L(e){await _.cancelAccountClosure(e.id),c.success(`已取消 `+e.username+` 的注销流程`),P()}async function R(e){M.value=e.nickname||e.username,j.value=!0,N.value=await _.userStats(e.id)}return(t,i)=>{let a=e(`a-button`),c=e(`a-input-search`),_=e(`a-tag`),z=e(`a-popconfirm`),B=e(`a-table`),V=e(`a-statistic`),H=e(`a-card`),U=e(`a-col`),W=e(`a-row`),G=e(`a-modal`);return r(),u(d,null,[s(`div`,v,[i[3]||=s(`h2`,null,`用户管理`,-1),o(a,{onClick:P},{default:n(()=>[o(l(g)),i[2]||=m(` 刷新`,-1)]),_:1})]),s(`div`,y,[o(c,{value:O.value,"onUpdate:value":i[0]||=e=>O.value=e,placeholder:`搜索用户名...`,style:{"max-width":`280px`},onSearch:F},null,8,[`value`])]),o(B,{columns:A,dataSource:T.value,loading:D.value,rowKey:`id`,size:`small`,pagination:{current:k.value,total:E.value,pageSize:w,showTotal:e=>`${e}`,onChange:e=>{k.value=e,P()}}},{bodyCell:n(({column:e,record:t})=>[e.key===`mode`?(r(),p(_,{key:0,color:t.appMode===`ai`?`blue`:`green`},{default:n(()=>[m(f(t.appMode===`ai`?`全AI`:`普通`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`comp`?(r(),u(d,{key:1},[t.companion?(r(),u(`span`,b,`形象:`+f(t.companion.avatarKey)+` · 性格:`+f(t.companion.personaKey),1)):(r(),u(`span`,x,`未设置`))],64)):h(``,!0),e.key===`tx`?(r(),u(d,{key:2},[m(f(t.txCount)+` `,1),s(`span`,S,`(AI:`+f(t.aiTxCount)+`)`,1)],64)):h(``,!0),e.key===`status`?(r(),u(d,{key:3},[t.accountClosureScheduledAt?(r(),p(_,{key:0,color:`orange`},{default:n(()=>[...i[4]||=[m(`注销中`,-1)]]),_:1})):(r(),p(_,{key:1,color:t.isBanned?`red`:`default`},{default:n(()=>[m(f(t.isBanned?`已封`:`正常`),1)]),_:2},1032,[`color`])),t.accountClosureScheduledAt?(r(),u(`div`,C,f(t.accountClosureScheduledAt.split(`T`)[0])+` 删除 `,1)):h(``,!0)],64)):h(``,!0),e.key===`reg`?(r(),u(d,{key:4},[m(f(t.createdAt?.split(`T`)[0]),1)],64)):h(``,!0),e.key===`login`?(r(),u(d,{key:5},[m(f(t.lastLoginAt?t.lastLoginAt.split(`T`)[0]:`从未`),1)],64)):h(``,!0),e.key===`act`?(r(),u(d,{key:6},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>R(t)},{default:n(()=>[...i[5]||=[m(`📊 统计`,-1)]]),_:1},8,[`onClick`]),t.accountClosureScheduledAt?(r(),p(z,{key:0,title:`确定取消该用户的注销流程?`,onConfirm:e=>L(t)},{default:n(()=>[o(a,{size:`small`,type:`primary`},{default:n(()=>[...i[6]||=[m(`取消注销`,-1)]]),_:1})]),_:1},8,[`onConfirm`])):(r(),p(z,{key:1,title:t.isBanned?`确定解封?`:`确定封禁?`,onConfirm:e=>I(t)},{default:n(()=>[o(a,{size:`small`,danger:!t.isBanned},{default:n(()=>[m(f(t.isBanned?`解封`:`封禁`),1)]),_:2},1032,[`danger`])]),_:2},1032,[`title`,`onConfirm`]))],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`,`pagination`]),o(G,{open:j.value,"onUpdate:open":i[1]||=e=>j.value=e,title:`${M.value} 使用统计`,footer:null,width:420},{default:n(()=>[N.value?(r(),p(W,{key:0,gutter:12},{default:n(()=>[o(U,{span:8},{default:n(()=>[o(H,{size:`small`},{default:n(()=>[o(V,{title:`总账单`,value:N.value.totalTransactions},null,8,[`value`])]),_:1})]),_:1}),o(U,{span:8},{default:n(()=>[o(H,{size:`small`},{default:n(()=>[o(V,{title:`AI 记账`,value:N.value.aiBooked},null,8,[`value`])]),_:1})]),_:1}),o(U,{span:8},{default:n(()=>[o(H,{size:`small`},{default:n(()=>[o(V,{title:`AI 准确率`,value:N.value.aiAccuracy,suffix:`%`,"value-style":{color:N.value.aiAccuracy>=70?`#00B386`:`#F0642D`}},null,8,[`value`,`value-style`])]),_:1})]),_:1})]),_:1})):h(``,!0)]),_:1},8,[`open`,`title`])],64)}}});export{T as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
html,body{width:100%;height:100%}input::-ms-clear{display:none}input::-ms-reveal{display:none}*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:#0000;font-family:sans-serif;line-height:1.15}@-ms-viewport{width:device-width}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}address{font-style:normal;line-height:inherit;margin-bottom:1em}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;font-size:1em}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{text-align:left;caption-side:bottom;padding-top:.75em;padding-bottom:.3em}input,button,select,optgroup,textarea{color:inherit;font-size:inherit;font-family:inherit;line-height:inherit;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner{border-style:none;padding:0}[type=button]::-moz-focus-inner{border-style:none;padding:0}[type=reset]::-moz-focus-inner{border-style:none;padding:0}[type=submit]::-moz-focus-inner{border-style:none;padding:0}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{resize:vertical;overflow:auto}fieldset{border:0;min-width:0;margin:0;padding:0}legend{width:100%;max-width:100%;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal;margin-bottom:.5em;padding:0;display:block}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button{height:auto}[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button{-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{background-color:#feffe6;padding:.2em}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-web</title>
<script type="module" crossorigin src="/assets/index-BSN4-dIH.js"></script>
<link rel="modulepreload" crossorigin href="/assets/config-provider-q7ATIdCu.js">
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-CeylGsUo.js">
<link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-CVrW_3-b.js">
<link rel="modulepreload" crossorigin href="/assets/TeamOutlined-0klbs6LP.js">
<link rel="stylesheet" crossorigin href="/assets/index-B6VCboLO.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -0,0 +1,57 @@
namespace MiaoJiZhang.Domain.Entities;
/// <summary>
/// 后台可配置内容(决策:App 名称等品牌位后台配置;性格 Prompt/文案/表情包后台管理)。
/// 统一 key-value + 版本号,客户端按版本增量拉取。
/// </summary>
public class AppConfig
{
public long Id { get; set; }
/// <summary>如 brand.app_name / brand.logo_url / llm.model / limit.daily_ai_messages</summary>
public string Key { get; set; } = null!;
public string Value { get; set; } = null!;
public int Version { get; set; }
public DateTime UpdatedAt { get; set; }
}
/// <summary>AI 性格库(后台管理,Prompt 可调不发版)</summary>
public class AiPersona
{
public long Id { get; set; }
/// <summary>如 sassy_cat / gentle / strict / meme</summary>
public string Key { get; set; } = null!;
public string Name { get; set; } = null!;
public string Description { get; set; } = null!;
public string SampleLine { get; set; } = null!;
/// <summary>系统提示词模板,支持 {avatar_tic} 等占位符</summary>
public string PromptTemplate { get; set; } = null!;
public int Version { get; set; }
public bool IsEnabled { get; set; } = true;
}
/// <summary>AI 形象库(后台管理;口癖跟随形象——决策 20)</summary>
public class AiAvatar
{
public long Id { get; set; }
/// <summary>cat / dog / robot ...</summary>
public string Key { get; set; } = null!;
public string DefaultName { get; set; } = null!;
/// <summary>口癖后缀,如 喵 / 汪 / 空字符串</summary>
public string SpeechTic { get; set; } = "";
public string? ImageUrl { get; set; }
public bool IsEnabled { get; set; } = true;
}
/// <summary>表情包库(后台管理)</summary>
public class Sticker
{
public long Id { get; set; }
public string Key { get; set; } = null!;
public string Label { get; set; } = null!;
public string? ImageUrl { get; set; }
/// <summary>分组:ai_exclusive / classic ...</summary>
public string GroupKey { get; set; } = "classic";
/// <summary>触发场景标签,逗号分隔:over_budget,salary,forgive</summary>
public string? TriggerTags { get; set; }
public bool IsEnabled { get; set; } = true;
}
@@ -0,0 +1,92 @@
using MiaoJiZhang.Domain.Enums;
namespace MiaoJiZhang.Domain.Entities;
/// <summary>账本(支持多账本/共享账本)</summary>
public class Ledger
{
public long Id { get; set; }
public long OwnerId { get; set; }
public User Owner { get; set; } = null!;
public string Name { get; set; } = null!;
public string IconKey { get; set; } = "wallet";
public bool IsDefault { get; set; }
public DateTime CreatedAt { get; set; }
public List<Transaction> Transactions { get; set; } = [];
public List<Budget> Budgets { get; set; } = [];
}
/// <summary>分类(系统默认分类 UserId 为 null;用户自定义分类挂 UserId</summary>
public class Category
{
public long Id { get; set; }
/// <summary>null = 系统默认分类(后台可管理)</summary>
public long? UserId { get; set; }
public TransactionType Type { get; set; }
public string Name { get; set; } = null!;
public string IconKey { get; set; } = "tag";
public string ColorKey { get; set; } = "mint";
public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
}
/// <summary>账单</summary>
public class Transaction
{
public long Id { get; set; }
public long LedgerId { get; set; }
public Ledger Ledger { get; set; } = null!;
public long UserId { get; set; }
public long CategoryId { get; set; }
public Category Category { get; set; } = null!;
public TransactionType Type { get; set; }
public decimal Amount { get; set; }
public string? Note { get; set; }
public string? PaymentMethod { get; set; }
public DateTime OccurredAt { get; set; }
// ---- AI 来源追溯(决策:可核对、可撤销) ----
public TransactionSource Source { get; set; } = TransactionSource.Manual;
/// <summary>AI 记账时用户的原话(追溯用)</summary>
public string? SourceText { get; set; }
/// <summary>关联的聊天消息 Id(追溯用)</summary>
public long? SourceChatMessageId { get; set; }
public string? ClientRequestId { get; set; }
// ---- 软删除(决策:直接入账 + 可撤销) ----
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
/// <summary>预算(CategoryId 为 null 表示总预算)</summary>
public class Budget
{
public long Id { get; set; }
public long LedgerId { get; set; }
public Ledger Ledger { get; set; } = null!;
public long UserId { get; set; }
/// <summary>null = 月度总预算</summary>
public long? CategoryId { get; set; }
/// <summary>格式 yyyyMM0 表示长期生效模板</summary>
public int Period { get; set; }
public decimal Amount { get; set; }
}
/// <summary>AI 聊天消息</summary>
public class ChatMessage
{
public long Id { get; set; }
public long UserId { get; set; }
public ChatRole Role { get; set; }
public ChatMessageType Type { get; set; }
/// <summary>文本内容 / 表情包 key / 卡片 JSON</summary>
public string Content { get; set; } = null!;
/// <summary>该消息产生的账单 IdBillCard 类型)</summary>
public long? TransactionId { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,77 @@
using MiaoJiZhang.Domain.Enums;
namespace MiaoJiZhang.Domain.Entities;
/// <summary>
/// 用户。当前仅用户名+密码注册登录;
/// Email/Phone/微信等字段为预留(决策 2026-07-18),暂不启用。
/// </summary>
public class User
{
public long Id { get; set; }
public string Username { get; set; } = null!;
public string PasswordHash { get; set; } = null!;
public string? Nickname { get; set; }
public string? AvatarUrl { get; set; }
// ---- 预留:快捷登录(暂不启用,仅建表占位) ----
public string? Email { get; set; }
public bool EmailVerified { get; set; }
public string? Phone { get; set; }
public bool PhoneVerified { get; set; }
public string? WeChatOpenId { get; set; }
public string? WeChatUnionId { get; set; }
public string? AppleUserId { get; set; }
// ---- 模式与 AI 伙伴设置 ----
public AppMode AppMode { get; set; } = AppMode.Normal;
public AiCompanionSetting? AiCompanion { get; set; }
public bool IsBanned { get; set; }
public int AuthVersion { get; set; }
public int AiChatLimit { get; set; } = 50;
public AiQuotaPeriod AiChatPeriod { get; set; } = AiQuotaPeriod.Day;
public int AiChatUsed { get; set; }
public DateTime AiChatWindowStartedAt { get; set; }
public DateTime? AccountClosureRequestedAt { get; set; }
public DateTime? AccountClosureScheduledAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastLoginAt { get; set; }
public List<Ledger> Ledgers { get; set; } = [];
public List<UserFeaturePermission> FeaturePermissions { get; set; } = [];
}
/// <summary>AI 伙伴设置(形象/昵称/性格/滑杆),1:1 User</summary>
public class UserFeaturePermission
{
public long Id { get; set; }
public long UserId { get; set; }
public User User { get; set; } = null!;
public string PermissionKey { get; set; } = null!;
public bool IsEnabled { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class AiCompanionSetting
{
public long Id { get; set; }
public long UserId { get; set; }
public User User { get; set; } = null!;
/// <summary>形象 keycat / dog / robot(后台形象库可扩展)</summary>
public string AvatarKey { get; set; } = "cat";
/// <summary>用户给 AI 起的名字;空则用形象默认名</summary>
public string? CustomName { get; set; }
/// <summary>性格 key,对应后台性格库</summary>
public string PersonaKey { get; set; } = "sassy_cat";
/// <summary>吐槽力度 0-100</summary>
public int RoastLevel { get; set; } = 60;
/// <summary>表情包频率 0-100</summary>
public int StickerFrequency { get; set; } = 70;
/// <summary>主动提醒频率 0-100(映射:关/每周/每天/实时)</summary>
public int ProactiveLevel { get; set; } = 40;
}
+76
View File
@@ -0,0 +1,76 @@
namespace MiaoJiZhang.Domain.Enums;
public enum AiQuotaPeriod
{
Day = 0,
Week = 1,
Month = 2,
}
/// <summary>App 模式(决策:双模式)</summary>
public enum AppMode
{
/// <summary>普通记账模式</summary>
Normal = 0,
/// <summary>全 AI 模式</summary>
AiFirst = 1,
}
/// <summary>账单类型</summary>
public enum TransactionType
{
Expense = 0,
Income = 1,
Transfer = 2,
}
/// <summary>账单来源(决策:AI 记账可追溯)</summary>
public enum TransactionSource
{
Manual = 0,
AiChat = 1,
Voice = 2,
ReceiptOcr = 3,
Screenshot = 4,
Accessibility = 5,
Notification = 6,
RecognitionAi = 7,
LocalOcr = 8,
}
public static class TransactionSourceRules
{
public static readonly TransactionSource[] AiAssisted =
[
TransactionSource.AiChat,
TransactionSource.Voice,
TransactionSource.ReceiptOcr,
TransactionSource.Screenshot,
TransactionSource.RecognitionAi,
];
public static bool IsAiAssisted(this TransactionSource source) =>
source is TransactionSource.AiChat
or TransactionSource.Voice
or TransactionSource.ReceiptOcr
or TransactionSource.Screenshot
or TransactionSource.RecognitionAi;
}
/// <summary>聊天消息角色</summary>
public enum ChatRole
{
User = 0,
Assistant = 1,
System = 2,
}
/// <summary>聊天消息类型</summary>
public enum ChatMessageType
{
Text = 0,
Sticker = 1,
BillCard = 2,
ReportCard = 3,
ContextBoundary = 4,
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MiaoJiZhang.Domain\MiaoJiZhang.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,100 @@
using MiaoJiZhang.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace MiaoJiZhang.Infrastructure.Persistence;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
public DbSet<UserFeaturePermission> UserFeaturePermissions => Set<UserFeaturePermission>();
public DbSet<AiCompanionSetting> AiCompanionSettings => Set<AiCompanionSetting>();
public DbSet<Ledger> Ledgers => Set<Ledger>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<Transaction> Transactions => Set<Transaction>();
public DbSet<Budget> Budgets => Set<Budget>();
public DbSet<ChatMessage> ChatMessages => Set<ChatMessage>();
public DbSet<AppConfig> AppConfigs => Set<AppConfig>();
public DbSet<AiPersona> AiPersonas => Set<AiPersona>();
public DbSet<AiAvatar> AiAvatars => Set<AiAvatar>();
public DbSet<Sticker> Stickers => Set<Sticker>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<User>(e =>
{
e.HasIndex(x => x.Username).IsUnique();
e.Property(x => x.Username).HasMaxLength(32);
e.Property(x => x.Nickname).HasMaxLength(32);
// 预留字段索引(唯一但允许 null)
e.HasIndex(x => x.Email).IsUnique();
e.HasIndex(x => x.Phone).IsUnique();
e.HasIndex(x => x.WeChatOpenId).IsUnique();
e.HasIndex(x => x.AccountClosureScheduledAt);
e.HasOne(x => x.AiCompanion).WithOne(x => x.User)
.HasForeignKey<AiCompanionSetting>(x => x.UserId);
});
b.Entity<UserFeaturePermission>(e =>
{
e.Property(x => x.PermissionKey).HasMaxLength(32);
e.HasIndex(x => new { x.UserId, x.PermissionKey }).IsUnique();
e.HasOne(x => x.User).WithMany(x => x.FeaturePermissions)
.HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Transaction>(e =>
{
e.Property(x => x.Amount).HasPrecision(12, 2);
e.HasIndex(x => new { x.LedgerId, x.OccurredAt });
e.HasIndex(x => new { x.UserId, x.IsDeleted });
e.Property(x => x.ClientRequestId).HasMaxLength(64);
e.HasIndex(x => new { x.UserId, x.ClientRequestId }).IsUnique();
e.HasQueryFilter(x => !x.IsDeleted); // 软删除全局过滤
});
b.Entity<Category>(e => e.Property(x => x.ColorKey).HasMaxLength(32));
b.Entity<Budget>(e =>
{
e.Property(x => x.Amount).HasPrecision(12, 2);
e.HasIndex(x => new { x.LedgerId, x.Period, x.CategoryId }).IsUnique();
});
b.Entity<ChatMessage>(e => e.HasIndex(x => new { x.UserId, x.CreatedAt }));
b.Entity<AppConfig>(e => e.HasIndex(x => x.Key).IsUnique());
b.Entity<AiPersona>(e => e.HasIndex(x => x.Key).IsUnique());
b.Entity<AiAvatar>(e => e.HasIndex(x => x.Key).IsUnique());
b.Entity<Sticker>(e => e.HasIndex(x => x.Key).IsUnique());
// MySQL DATETIME has no timezone metadata. Preserve stored UTC wall-clock
// values and restore DateTimeKind.Utc whenever EF materializes them.
var utcDateTimeConverter = new ValueConverter<DateTime, DateTime>(
value => value.Kind == DateTimeKind.Local
? value.ToUniversalTime()
: DateTime.SpecifyKind(value, DateTimeKind.Utc),
value => DateTime.SpecifyKind(value, DateTimeKind.Utc));
var nullableUtcDateTimeConverter = new ValueConverter<DateTime?, DateTime?>(
value => value.HasValue
? value.Value.Kind == DateTimeKind.Local
? value.Value.ToUniversalTime()
: DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
: value,
value => value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
: value);
foreach (var property in b.Model.GetEntityTypes().SelectMany(entity => entity.GetProperties()))
{
if (property.ClrType == typeof(DateTime))
{
property.SetValueConverter(utcDateTimeConverter);
}
else if (property.ClrType == typeof(DateTime?))
{
property.SetValueConverter(nullableUtcDateTimeConverter);
}
}
}
}
@@ -0,0 +1,128 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Infrastructure.Persistence;
/// <summary>启动种子:默认分类、AI 形象/性格、品牌配置</summary>
public static class DbSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
if (!await db.AiAvatars.AnyAsync())
{
db.AiAvatars.AddRange(
new AiAvatar { Key = "cat", DefaultName = "小账喵", SpeechTic = "喵" },
new AiAvatar { Key = "dog", DefaultName = "阿福汪", SpeechTic = "汪" },
new AiAvatar { Key = "robot", DefaultName = "账小智", SpeechTic = "" });
}
if (!await db.AiPersonas.AnyAsync())
{
db.AiPersonas.AddRange(
new AiPersona { Key = "sassy_cat", Name = "毒舌猫娘", Description = "嘴上嫌弃,心里关心,乱花钱会被吐槽", SampleLine = "又点外卖?你的钱包已经瘦成纸片了{tic}!", PromptTemplate = "你是用户的记账助手,性格毒舌但关心用户。回复末尾口癖:{tic}。", Version = 1 },
new AiPersona { Key = "gentle", Name = "温柔小暖", Description = "永远鼓励你,温柔提醒不施压", SampleLine = "记好啦,偶尔犒劳自己也是应该的呀", PromptTemplate = "你是用户的记账助手,性格温柔鼓励。口癖:{tic}。", Version = 1 },
new AiPersona { Key = "strict", Name = "严格管家", Description = "专业理性,盯紧预算,数据说话", SampleLine = "本笔支出使餐饮预算达到 87%,请注意。", PromptTemplate = "你是用户的记账管家,专业理性,用数据说话,不用口癖。", Version = 1 },
new AiPersona { Key = "meme", Name = "沙雕损友", Description = "玩梗高手,表情包狂魔,快乐记账", SampleLine = "家人们谁懂啊,他又双叒叕买奶茶了", PromptTemplate = "你是用户的记账损友,爱玩梗爱发表情包。口癖:{tic}。", Version = 1 });
}
if (!await db.Categories.AnyAsync(c => c.UserId == null))
{
string[,] expense =
{
{ "餐饮", "food", "coral" },
{ "饮品", "cup", "cyan" },
{ "购物", "cart", "blue" },
{ "交通", "metro", "teal" },
{ "住房", "house", "sand" },
{ "娱乐", "game", "violet" },
{ "医疗", "pill", "red" },
{ "学习", "book", "indigo" },
{ "服饰", "shirt", "plum" },
{ "人情", "gift", "rose" },
{ "旅行", "plane", "sky" },
{ "其他", "tag", "graphite" },
};
for (var i = 0; i < expense.GetLength(0); i++)
db.Categories.Add(new Category
{
Type = TransactionType.Expense,
Name = expense[i, 0],
IconKey = expense[i, 1],
ColorKey = expense[i, 2],
SortOrder = i,
});
string[,] income =
{
{ "工资", "money", "mint" },
{ "兼职", "briefcase", "forest" },
{ "理财", "chart", "navy" },
{ "红包", "gift", "orange" },
{ "报销", "card", "amber" },
{ "奖金", "sparkle", "aqua" },
{ "其他", "tag", "lime" },
};
for (var i = 0; i < income.GetLength(0); i++)
db.Categories.Add(new Category
{
Type = TransactionType.Income,
Name = income[i, 0],
IconKey = income[i, 1],
ColorKey = income[i, 2],
SortOrder = i,
});
}
if (!await db.Stickers.AnyAsync())
{
db.Stickers.AddRange(
new Sticker { Key = "salary", Label = "发工资啦", GroupKey = "ai_exclusive", TriggerTags = "salary" },
new Sticker { Key = "forgive", Label = "求原谅", GroupKey = "ai_exclusive", TriggerTags = "forgive" },
new Sticker { Key = "fire", Label = "剁手警告", GroupKey = "ai_exclusive", TriggerTags = "over_budget" },
new Sticker { Key = "empty_wallet", Label = "钱包空空", GroupKey = "ai_exclusive" },
new Sticker { Key = "angry", Label = "生气", GroupKey = "ai_exclusive" },
new Sticker { Key = "happy", Label = "开心", GroupKey = "ai_exclusive" },
new Sticker { Key = "treat", Label = "犒劳自己", GroupKey = "classic" },
new Sticker { Key = "shopping_joy", Label = "购物狂喜", GroupKey = "classic" });
}
if (!await db.AppConfigs.AnyAsync())
{
var now = DateTime.UtcNow;
db.AppConfigs.AddRange(
new AppConfig { Key = "brand.app_name", Value = "记之", Version = 1, UpdatedAt = now },
new AppConfig { Key = "brand.slogan", Value = "会聊天的记账本 · 让 AI 帮你管钱", Version = 1, UpdatedAt = now },
new AppConfig { Key = "brand.logo_url", Value = "", Version = 1, UpdatedAt = now },
new AppConfig { Key = "llm.protocol", Value = "chat_completions", Version = 1, UpdatedAt = now },
new AppConfig { Key = "llm.base_url", Value = "https://api.openai.com/v1", Version = 1, UpdatedAt = now },
new AppConfig { Key = "llm.model", Value = "gpt-4o-mini", Version = 1, UpdatedAt = now },
new AppConfig { Key = "llm.max_tokens", Value = "1024", Version = 1, UpdatedAt = now },
new AppConfig { Key = "llm.temperature", Value = "0.8", Version = 1, UpdatedAt = now },
new AppConfig { Key = "limit.daily_ai_messages", Value = "200", Version = 1, UpdatedAt = now },
new AppConfig { Key = "limit.daily_ai_messages_per_user", Value = "50", Version = 1, UpdatedAt = now },
new AppConfig { Key = "limit.max_monthly_budget", Value = "99999999", Version = 1, UpdatedAt = now },
new AppConfig { Key = "feature.ocr_enabled", Value = "true", Version = 1, UpdatedAt = now },
new AppConfig { Key = "feature.voice_enabled", Value = "true", Version = 1, UpdatedAt = now },
new AppConfig { Key = "feature.ai_auto_book", Value = "true", Version = 1, UpdatedAt = now },
new AppConfig { Key = "feature.sticker_enabled", Value = "true", Version = 1, UpdatedAt = now },
new AppConfig { Key = "system.default_ledger_name", Value = "日常账本", Version = 1, UpdatedAt = now },
new AppConfig { Key = "system.max_ledgers_per_user", Value = "10", Version = 1, UpdatedAt = now },
new AppConfig { Key = "feature.screenshot_bookkeeping_enabled", Value = "true", Version = 1, UpdatedAt = now },
new AppConfig { Key = "permission.default.ai_enabled", Value = "true", Version = 1, UpdatedAt = now },
new AppConfig { Key = "quota.default_ai_chat_limit", Value = "50", Version = 1, UpdatedAt = now },
new AppConfig { Key = "quota.default_ai_chat_period", Value = "day", Version = 1, UpdatedAt = now });
}
var appName = await db.AppConfigs.FirstOrDefaultAsync(config => config.Key == "brand.app_name");
if (appName?.Value is "喵记账" or "喵记")
{
appName.Value = "记之";
appName.Version++;
appName.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
}
}
@@ -0,0 +1,548 @@
// <auto-generated />
using System;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260720063643_InitialBaseline")]
partial class InitialBaseline
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.AiAvatar", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("DefaultName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("ImageUrl")
.HasColumnType("longtext");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<string>("SpeechTic")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("AiAvatars");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.AiCompanionSetting", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AvatarKey")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("CustomName")
.HasColumnType("longtext");
b.Property<string>("PersonaKey")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("ProactiveLevel")
.HasColumnType("int");
b.Property<int>("RoastLevel")
.HasColumnType("int");
b.Property<int>("StickerFrequency")
.HasColumnType("int");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("AiCompanionSettings");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.AiPersona", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Description")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("PromptTemplate")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("SampleLine")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("AiPersonas");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.AppConfig", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("AppConfigs");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Budget", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(12, 2)
.HasColumnType("decimal(12,2)");
b.Property<long?>("CategoryId")
.HasColumnType("bigint");
b.Property<long>("LedgerId")
.HasColumnType("bigint");
b.Property<int>("Period")
.HasColumnType("int");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("LedgerId", "Period", "CategoryId")
.IsUnique();
b.ToTable("Budgets");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Category", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("IconKey")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<long?>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("Categories");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.ChatMessage", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Content")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<long?>("TransactionId")
.HasColumnType("bigint");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId", "CreatedAt");
b.ToTable("ChatMessages");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Ledger", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("IconKey")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("IsDefault")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("OwnerId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("OwnerId");
b.ToTable("Ledgers");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Sticker", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("GroupKey")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("ImageUrl")
.HasColumnType("longtext");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<string>("Label")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TriggerTags")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("Stickers");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Transaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(12, 2)
.HasColumnType("decimal(12,2)");
b.Property<long>("CategoryId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<long>("LedgerId")
.HasColumnType("bigint");
b.Property<string>("Note")
.HasColumnType("longtext");
b.Property<DateTime>("OccurredAt")
.HasColumnType("datetime(6)");
b.Property<string>("PaymentMethod")
.HasColumnType("longtext");
b.Property<int>("Source")
.HasColumnType("int");
b.Property<long?>("SourceChatMessageId")
.HasColumnType("bigint");
b.Property<string>("SourceText")
.HasColumnType("longtext");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("LedgerId", "OccurredAt");
b.HasIndex("UserId", "IsDeleted");
b.ToTable("Transactions");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.User", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AppMode")
.HasColumnType("int");
b.Property<string>("AppleUserId")
.HasColumnType("longtext");
b.Property<string>("AvatarUrl")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.HasColumnType("varchar(255)");
b.Property<bool>("EmailVerified")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsBanned")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime(6)");
b.Property<string>("Nickname")
.HasMaxLength(32)
.HasColumnType("varchar(32)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Phone")
.HasColumnType("varchar(255)");
b.Property<bool>("PhoneVerified")
.HasColumnType("tinyint(1)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)");
b.Property<string>("WeChatOpenId")
.HasColumnType("varchar(255)");
b.Property<string>("WeChatUnionId")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Phone")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.HasIndex("WeChatOpenId")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.AiCompanionSetting", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.User", "User")
.WithOne("AiCompanion")
.HasForeignKey("MiaoJiZhang.Domain.Entities.AiCompanionSetting", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Budget", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.Ledger", "Ledger")
.WithMany("Budgets")
.HasForeignKey("LedgerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Ledger");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Ledger", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.User", "Owner")
.WithMany("Ledgers")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Transaction", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MiaoJiZhang.Domain.Entities.Ledger", "Ledger")
.WithMany("Transactions")
.HasForeignKey("LedgerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Ledger");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Ledger", b =>
{
b.Navigation("Budgets");
b.Navigation("Transactions");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.User", b =>
{
b.Navigation("AiCompanion");
b.Navigation("Ledgers");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,431 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialBaseline : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "AiAvatars",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Key = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DefaultName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SpeechTic = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ImageUrl = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AiAvatars", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "AiPersonas",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Key = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SampleLine = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
PromptTemplate = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Version = table.Column<int>(type: "int", nullable: false),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AiPersonas", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "AppConfigs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Key = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Value = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Version = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppConfigs", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<long>(type: "bigint", nullable: true),
Type = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IconKey = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SortOrder = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "ChatMessages",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<long>(type: "bigint", nullable: false),
Role = table.Column<int>(type: "int", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Content = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TransactionId = table.Column<long>(type: "bigint", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChatMessages", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Stickers",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Key = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Label = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ImageUrl = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
GroupKey = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TriggerTags = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Stickers", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Username = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
PasswordHash = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Nickname = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
AvatarUrl = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Email = table.Column<string>(type: "varchar(255)", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
EmailVerified = table.Column<bool>(type: "tinyint(1)", nullable: false),
Phone = table.Column<string>(type: "varchar(255)", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PhoneVerified = table.Column<bool>(type: "tinyint(1)", nullable: false),
WeChatOpenId = table.Column<string>(type: "varchar(255)", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
WeChatUnionId = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
AppleUserId = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
AppMode = table.Column<int>(type: "int", nullable: false),
IsBanned = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LastLoginAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "AiCompanionSettings",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<long>(type: "bigint", nullable: false),
AvatarKey = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CustomName = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PersonaKey = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
RoastLevel = table.Column<int>(type: "int", nullable: false),
StickerFrequency = table.Column<int>(type: "int", nullable: false),
ProactiveLevel = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AiCompanionSettings", x => x.Id);
table.ForeignKey(
name: "FK_AiCompanionSettings_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Ledgers",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
OwnerId = table.Column<long>(type: "bigint", nullable: false),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IconKey = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IsDefault = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Ledgers", x => x.Id);
table.ForeignKey(
name: "FK_Ledgers_Users_OwnerId",
column: x => x.OwnerId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Budgets",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
LedgerId = table.Column<long>(type: "bigint", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
CategoryId = table.Column<long>(type: "bigint", nullable: true),
Period = table.Column<int>(type: "int", nullable: false),
Amount = table.Column<decimal>(type: "decimal(12,2)", precision: 12, scale: 2, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Budgets", x => x.Id);
table.ForeignKey(
name: "FK_Budgets_Ledgers_LedgerId",
column: x => x.LedgerId,
principalTable: "Ledgers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Transactions",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
LedgerId = table.Column<long>(type: "bigint", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
CategoryId = table.Column<long>(type: "bigint", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Amount = table.Column<decimal>(type: "decimal(12,2)", precision: 12, scale: 2, nullable: false),
Note = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PaymentMethod = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
OccurredAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Source = table.Column<int>(type: "int", nullable: false),
SourceText = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SourceChatMessageId = table.Column<long>(type: "bigint", nullable: true),
IsDeleted = table.Column<bool>(type: "tinyint(1)", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Transactions", x => x.Id);
table.ForeignKey(
name: "FK_Transactions_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Transactions_Ledgers_LedgerId",
column: x => x.LedgerId,
principalTable: "Ledgers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_AiAvatars_Key",
table: "AiAvatars",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AiCompanionSettings_UserId",
table: "AiCompanionSettings",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AiPersonas_Key",
table: "AiPersonas",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AppConfigs_Key",
table: "AppConfigs",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Budgets_LedgerId_Period_CategoryId",
table: "Budgets",
columns: new[] { "LedgerId", "Period", "CategoryId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ChatMessages_UserId_CreatedAt",
table: "ChatMessages",
columns: new[] { "UserId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Ledgers_OwnerId",
table: "Ledgers",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_Stickers_Key",
table: "Stickers",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Transactions_CategoryId",
table: "Transactions",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_Transactions_LedgerId_OccurredAt",
table: "Transactions",
columns: new[] { "LedgerId", "OccurredAt" });
migrationBuilder.CreateIndex(
name: "IX_Transactions_UserId_IsDeleted",
table: "Transactions",
columns: new[] { "UserId", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_Users_Email",
table: "Users",
column: "Email",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_Phone",
table: "Users",
column: "Phone",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_Username",
table: "Users",
column: "Username",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_WeChatOpenId",
table: "Users",
column: "WeChatOpenId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AiAvatars");
migrationBuilder.DropTable(
name: "AiCompanionSettings");
migrationBuilder.DropTable(
name: "AiPersonas");
migrationBuilder.DropTable(
name: "AppConfigs");
migrationBuilder.DropTable(
name: "Budgets");
migrationBuilder.DropTable(
name: "ChatMessages");
migrationBuilder.DropTable(
name: "Stickers");
migrationBuilder.DropTable(
name: "Transactions");
migrationBuilder.DropTable(
name: "Categories");
migrationBuilder.DropTable(
name: "Ledgers");
migrationBuilder.DropTable(
name: "Users");
}
}
}
@@ -0,0 +1,68 @@
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MiaoJiZhang.Infrastructure.Persistence.Migrations;
[DbContext(typeof(AppDbContext))]
[Migration("20260721120000_AccountClosureAndCategoryIcons")]
public partial class AccountClosureAndCategoryIcons : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "AccountClosureRequestedAt",
table: "Users",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "AccountClosureScheduledAt",
table: "Users",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AuthVersion",
table: "Users",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_Users_AccountClosureScheduledAt",
table: "Users",
column: "AccountClosureScheduledAt");
migrationBuilder.Sql(
"UPDATE Categories SET IconKey = 'cart' WHERE IconKey = 'shopping';");
migrationBuilder.Sql(
"UPDATE Categories SET IconKey = 'metro' WHERE IconKey = 'transport';");
migrationBuilder.Sql(
"UPDATE Categories SET IconKey = 'house' WHERE IconKey = 'home';");
migrationBuilder.Sql(
"UPDATE Categories SET IconKey = 'money' WHERE IconKey = 'salary';");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_AccountClosureScheduledAt",
table: "Users");
migrationBuilder.DropColumn(
name: "AccountClosureRequestedAt",
table: "Users");
migrationBuilder.DropColumn(
name: "AccountClosureScheduledAt",
table: "Users");
migrationBuilder.DropColumn(
name: "AuthVersion",
table: "Users");
}
}
@@ -0,0 +1,50 @@
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MiaoJiZhang.Infrastructure.Persistence.Migrations;
[DbContext(typeof(AppDbContext))]
[Migration("20260721173000_CategoryColors")]
public partial class CategoryColors : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ColorKey",
table: "Categories",
type: "varchar(32)",
maxLength: 32,
nullable: false,
defaultValue: "mint");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'coral' WHERE UserId IS NULL AND Type = 0 AND Name = '餐饮';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'cyan' WHERE UserId IS NULL AND Type = 0 AND Name = '饮品';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'blue' WHERE UserId IS NULL AND Type = 0 AND Name = '购物';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'teal' WHERE UserId IS NULL AND Type = 0 AND Name = '交通';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'sand' WHERE UserId IS NULL AND Type = 0 AND Name = '住房';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'violet' WHERE UserId IS NULL AND Type = 0 AND Name = '娱乐';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'red' WHERE UserId IS NULL AND Type = 0 AND Name = '医疗';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'indigo' WHERE UserId IS NULL AND Type = 0 AND Name = '学习';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'plum' WHERE UserId IS NULL AND Type = 0 AND Name = '服饰';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'rose' WHERE UserId IS NULL AND Type = 0 AND Name = '人情';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'sky' WHERE UserId IS NULL AND Type = 0 AND Name = '旅行';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'graphite' WHERE UserId IS NULL AND Type = 0 AND Name = '其他';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'mint' WHERE UserId IS NULL AND Type = 1 AND Name = '工资';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'forest' WHERE UserId IS NULL AND Type = 1 AND Name = '兼职';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'navy' WHERE UserId IS NULL AND Type = 1 AND Name = '理财';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'orange' WHERE UserId IS NULL AND Type = 1 AND Name = '红包';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'amber' WHERE UserId IS NULL AND Type = 1 AND Name = '报销';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'aqua' WHERE UserId IS NULL AND Type = 1 AND Name = '奖金';");
migrationBuilder.Sql("UPDATE Categories SET ColorKey = 'lime' WHERE UserId IS NULL AND Type = 1 AND Name = '其他';");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ColorKey",
table: "Categories");
}
}

Some files were not shown because too many files have changed in this diff Show More