Add domestic vendor push infrastructure

This commit is contained in:
2026-07-26 01:45:59 +08:00
parent 7cca34b331
commit 0738953e6d
77 changed files with 6470 additions and 855 deletions
+2
View File
@@ -40,6 +40,8 @@ filing/
*.p12
*.jks
*.keystore
frontend/android/app/libs/push/*.aar
!frontend/android/app/libs/push/README.md
!frontend/android/app/src/internal/res/raw/
!frontend/android/app/src/internal/res/raw/sakura_frp_test_ca.pem
+4 -2
View File
@@ -2,7 +2,8 @@
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'
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined,
NotificationOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -20,6 +21,7 @@ const nav = [
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
]
</script>
@@ -37,7 +39,7 @@ const nav = [
<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>
<div style="position:absolute;bottom:16px;left:20px;font-size:11px;color:#bbb">v20260726-0130</div>
</a-layout-sider>
<a-layout>
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 10px; min-height: 360px;">
+12 -3
View File
@@ -44,9 +44,18 @@ export const api = {
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),
}
deleteSysCategory: (id: number) => http.delete(`/api/admin/categories/${id}`),
testLlm: () => http.post('/api/admin/llm/test').then(r => r.data),
pushCampaigns: (params: { page: number; limit: number }) => http.get('/api/admin/push/campaigns', { params }).then(r => r.data),
estimatePushCampaign: (data: any) => http.post('/api/admin/push/campaigns/estimate', data).then(r => r.data),
createPushCampaign: (data: any) => http.post('/api/admin/push/campaigns', data).then(r => r.data),
updatePushCampaign: (id: number, data: any) => http.put(`/api/admin/push/campaigns/${id}`, data).then(r => r.data),
sendPushCampaign: (id: number, scheduledAt?: string) => http.post(`/api/admin/push/campaigns/${id}/send`, { scheduledAt }).then(r => r.data),
cancelPushCampaign: (id: number) => http.post(`/api/admin/push/campaigns/${id}/cancel`).then(r => r.data),
pushDevices: (params: { search?: string; limit?: number }) => http.get('/api/admin/push/devices', { params }).then(r => r.data),
testPush: (data: any) => http.post('/api/admin/push/test', data).then(r => r.data),
pushHealth: () => http.get('/api/admin/push/health').then(r => r.data),
}
export default http
+2 -1
View File
@@ -12,7 +12,8 @@ const router = createRouter({
{ 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') },
{ path: '/push', name: 'PushCampaigns', component: () => import('../views/PushCampaigns.vue') },
],
})
export default router
export default router
+618
View File
@@ -0,0 +1,618 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import dayjs, { type Dayjs } from 'dayjs'
import { message } from 'ant-design-vue'
import {
EditOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
} from '@ant-design/icons-vue'
import { api } from '../api'
interface DeliveryCounts {
queued: number
sending: number
accepted: number
failed: number
skipped: number
}
interface Campaign {
id: number
publicId: string
state: string
title: string
body: string
category: string
action: string
entityId: string | null
flavor: string
provider: string | null
minVersionCode: number | null
maxVersionCode: number | null
targetUserId: number | null
ttlSeconds: number
scheduledAt: string | null
createdAt: string
startedAt: string | null
completedAt: string | null
cancelledAt: string | null
deliveries: DeliveryCounts
}
interface PushDevice {
id: number
userId: number
username: string
provider: string
packageName: string
flavor: string
appVersion: string
versionCode: number
notificationsAllowed: boolean
isActive: boolean
disabledReason: string | null
tokenSuffix: string
lastSeenAt: string
}
interface ProviderHealth {
provider: string
environments: Array<{ flavor: string; enabled: boolean; errors: string[] }>
}
interface PushHealth {
enabled: boolean
tokenEncryptionConfigured: boolean
providers: ProviderHealth[]
}
type SubmitMode = 'draft' | 'now' | 'scheduled'
const activeTab = ref('campaigns')
const campaigns = ref<Campaign[]>([])
const campaignTotal = ref(0)
const campaignPage = ref(1)
const pageSize = 20
const campaignLoading = ref(false)
const health = ref<PushHealth | null>(null)
const healthLoading = ref(false)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const editorSaving = ref(false)
const estimating = ref(false)
const estimate = ref<number | null>(null)
const scheduleAt = ref<Dayjs | null>(null)
const form = reactive(emptyCampaign())
const devices = ref<PushDevice[]>([])
const deviceLoading = ref(false)
const deviceSearch = ref('')
const testOpen = ref(false)
const testSaving = ref(false)
const testForm = reactive({
deviceId: undefined as number | undefined,
title: '',
body: '',
category: 'system',
action: 'none',
entityId: '',
})
const categoryLabels: Record<string, string> = {
system: '系统通知',
budget: '预算提醒',
operations: '运营通知',
}
const actionLabels: Record<string, string> = {
none: '仅打开应用',
home: '首页',
budget: '预算页面',
update: '版本更新',
}
const providerLabels: Record<string, string> = {
huawei: '华为',
honor: '荣耀',
xiaomi: '小米',
oppo: 'OPPO',
vivo: 'vivo',
meizu: '魅族',
}
const stateMeta: Record<string, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
scheduled: { label: '已定时', color: 'blue' },
queued: { label: '待发送', color: 'cyan' },
sending: { label: '发送中', color: 'processing' },
completed: { label: '已完成', color: 'success' },
partially_failed: { label: '部分失败', color: 'warning' },
cancelled: { label: '已取消', color: 'default' },
}
const campaignColumns = [
{ title: '内容', key: 'content', width: 310 },
{ title: '目标', key: 'target', width: 180 },
{ title: '状态', key: 'state', width: 100 },
{ title: '投递结果', key: 'deliveries', width: 220 },
{ title: '时间', key: 'time', width: 180 },
{ title: '操作', key: 'actions', width: 210 },
]
const deviceColumns = [
{ title: '设备', key: 'device', width: 220 },
{ title: '用户', key: 'user', width: 150 },
{ title: '厂商', key: 'provider', width: 90 },
{ title: '应用', key: 'app', width: 170 },
{ title: '状态', key: 'state', width: 120 },
{ title: '最后活跃', key: 'time', width: 165 },
{ title: '操作', key: 'actions', width: 100 },
]
const canEstimate = computed(() => form.title.trim() && form.body.trim())
onMounted(async () => {
await Promise.all([loadCampaigns(), loadHealth()])
})
function emptyCampaign() {
return {
title: '',
body: '',
category: 'system',
action: 'none',
entityId: '',
flavor: 'production',
provider: undefined as string | undefined,
minVersionCode: undefined as number | undefined,
maxVersionCode: undefined as number | undefined,
targetUserId: undefined as number | undefined,
ttlSeconds: 259200,
}
}
function campaignPayload() {
return {
title: form.title.trim(),
body: form.body.trim(),
category: form.category,
action: form.action,
entityId: form.entityId.trim() || null,
flavor: form.flavor,
provider: form.provider || null,
minVersionCode: form.minVersionCode ?? null,
maxVersionCode: form.maxVersionCode ?? null,
targetUserId: form.targetUserId ?? null,
ttlSeconds: form.ttlSeconds,
}
}
function assignForm(value: ReturnType<typeof emptyCampaign>) {
Object.assign(form, value)
}
async function loadCampaigns() {
campaignLoading.value = true
try {
const result = await api.pushCampaigns({ page: campaignPage.value, limit: pageSize })
campaigns.value = result.list
campaignTotal.value = result.total
} finally {
campaignLoading.value = false
}
}
async function loadHealth() {
healthLoading.value = true
try {
health.value = await api.pushHealth()
} catch (error) {
message.error(errorText(error, '推送服务状态读取失败'))
} finally {
healthLoading.value = false
}
}
async function loadDevices() {
deviceLoading.value = true
try {
devices.value = await api.pushDevices({ search: deviceSearch.value.trim() || undefined, limit: 100 })
} finally {
deviceLoading.value = false
}
}
function openCreate() {
editingId.value = null
estimate.value = null
scheduleAt.value = null
assignForm(emptyCampaign())
editorOpen.value = true
}
function openEdit(item: Campaign) {
editingId.value = item.id
estimate.value = null
scheduleAt.value = item.scheduledAt ? dayjs(normalizeUtc(item.scheduledAt)) : null
assignForm({
title: item.title,
body: item.body,
category: item.category,
action: item.action,
entityId: item.entityId || '',
flavor: item.flavor,
provider: item.provider || undefined,
minVersionCode: item.minVersionCode ?? undefined,
maxVersionCode: item.maxVersionCode ?? undefined,
targetUserId: item.targetUserId ?? undefined,
ttlSeconds: item.ttlSeconds,
})
editorOpen.value = true
}
async function estimateTargets() {
estimating.value = true
try {
const result = await api.estimatePushCampaign(campaignPayload())
estimate.value = result.devices
} catch (error) {
message.error(errorText(error, '目标设备估算失败'))
} finally {
estimating.value = false
}
}
async function submitCampaign(mode: SubmitMode) {
if (!form.title.trim() || !form.body.trim()) {
message.warning('请填写推送标题和正文')
return
}
if (mode === 'scheduled' && (!scheduleAt.value || !scheduleAt.value.isAfter(dayjs().add(5, 'second')))) {
message.warning('定时发送时间必须晚于当前时间')
return
}
editorSaving.value = true
try {
const saved = editingId.value
? await api.updatePushCampaign(editingId.value, campaignPayload())
: await api.createPushCampaign(campaignPayload())
if (mode !== 'draft') {
await api.sendPushCampaign(
saved.id,
mode === 'scheduled' ? scheduleAt.value?.toISOString() : undefined,
)
}
editorOpen.value = false
message.success(mode === 'draft' ? '草稿已保存' : mode === 'scheduled' ? '定时任务已保存' : '推送已进入发送队列')
await loadCampaigns()
} catch (error) {
message.error(errorText(error, '推送活动保存失败'))
} finally {
editorSaving.value = false
}
}
async function sendNow(item: Campaign) {
try {
await api.sendPushCampaign(item.id)
message.success('推送已进入发送队列')
await loadCampaigns()
} catch (error) {
message.error(errorText(error, '推送启动失败'))
}
}
async function cancelCampaign(item: Campaign) {
try {
await api.cancelPushCampaign(item.id)
message.success('推送活动已取消')
await loadCampaigns()
} catch (error) {
message.error(errorText(error, '取消失败'))
}
}
async function openTest(device?: PushDevice) {
if (!devices.value.length) await loadDevices()
Object.assign(testForm, {
deviceId: device?.id,
title: '',
body: '',
category: 'system',
action: 'none',
entityId: '',
})
testOpen.value = true
}
async function submitTest() {
if (!testForm.deviceId || !testForm.title.trim() || !testForm.body.trim()) {
message.warning('请选择设备并填写推送内容')
return
}
testSaving.value = true
try {
await api.testPush({
...testForm,
title: testForm.title.trim(),
body: testForm.body.trim(),
entityId: testForm.entityId.trim() || null,
})
testOpen.value = false
message.success('测试推送已进入发送队列')
await loadCampaigns()
} catch (error) {
message.error(errorText(error, '测试推送失败'))
} finally {
testSaving.value = false
}
}
function onTabChange(value: string) {
activeTab.value = value
if (value === 'devices' && !devices.value.length) loadDevices()
}
function isEditable(item: Campaign) {
return item.state === 'draft' || item.state === 'scheduled'
}
function isCancellable(item: Campaign) {
return ['draft', 'scheduled', 'queued'].includes(item.state) && !item.startedAt
}
function state(item: Campaign) {
return stateMeta[item.state] || { label: item.state, color: 'default' }
}
function normalizeUtc(value: string) {
return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`
}
function formatTime(value?: string | null) {
if (!value) return '-'
const date = new Date(normalizeUtc(value))
return Number.isNaN(date.getTime())
? '-'
: new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date).replaceAll('/', '-')
}
function errorText(error: unknown, fallback: string) {
const candidate = error as { response?: { data?: { message?: string; detail?: string; error?: string } } }
const data = candidate.response?.data
return data?.message || data?.detail || data?.error || fallback
}
</script>
<template>
<div class="page-header">
<div>
<h2>推送管理</h2>
<div class="subtle">厂商通道配置活动投递和设备联调</div>
</div>
<a-space>
<a-button @click="openTest()"><SendOutlined />测试设备</a-button>
<a-button type="primary" @click="openCreate"><PlusOutlined />新建推送</a-button>
</a-space>
</div>
<a-alert
v-if="health && (!health.enabled || !health.tokenEncryptionConfigured)"
type="warning"
show-icon
style="margin-bottom: 14px"
:message="!health.enabled ? '推送总开关未启用' : '设备令牌加密密钥未配置'"
description="完成服务端环境变量配置后再执行正式投递。" />
<a-spin :spinning="healthLoading">
<a-descriptions v-if="health" bordered size="small" :column="4" style="margin-bottom: 16px">
<a-descriptions-item label="服务">
<a-badge :status="health.enabled ? 'success' : 'default'" :text="health.enabled ? '已启用' : '未启用'" />
</a-descriptions-item>
<a-descriptions-item label="令牌加密">
<a-badge :status="health.tokenEncryptionConfigured ? 'success' : 'error'" :text="health.tokenEncryptionConfigured ? '已配置' : '缺少密钥'" />
</a-descriptions-item>
<a-descriptions-item v-for="item in health.providers" :key="item.provider" :label="providerLabels[item.provider] || item.provider">
<a-space size="small">
<a-tooltip v-for="env in item.environments" :key="env.flavor" :title="env.errors.join('、') || '配置完整'">
<a-tag :color="env.enabled && !env.errors.length ? 'success' : 'default'">
{{ env.flavor === 'production' ? '正式' : '内测' }}
</a-tag>
</a-tooltip>
</a-space>
</a-descriptions-item>
</a-descriptions>
</a-spin>
<a-tabs :active-key="activeTab" @change="onTabChange">
<a-tab-pane key="campaigns" tab="活动与历史">
<div class="toolbar">
<span class="subtle"> {{ campaignTotal }} 个活动</span>
<a-button size="small" @click="loadCampaigns"><ReloadOutlined />刷新</a-button>
</div>
<a-table
:columns="campaignColumns"
:data-source="campaigns"
:loading="campaignLoading"
row-key="id"
size="small"
:scroll="{ x: 1200 }"
:pagination="{
current: campaignPage,
pageSize,
total: campaignTotal,
showTotal: (total: number) => `共 ${total} 条`,
onChange: (page: number) => { campaignPage = page; loadCampaigns() },
}">
<template #bodyCell="{ column, record }: { column: { key: string }; record: Campaign }">
<template v-if="column.key === 'content'">
<div class="campaign-title">{{ record.title }}</div>
<div class="campaign-body">{{ record.body }}</div>
<a-tag>{{ categoryLabels[record.category] || record.category }}</a-tag>
<span class="action-label">{{ actionLabels[record.action] || record.action }}</span>
</template>
<template v-else-if="column.key === 'target'">
<div>{{ record.flavor === 'production' ? '正式环境' : '内测环境' }}</div>
<div class="subtle">
{{ record.provider ? providerLabels[record.provider] : '全部厂商' }}
<template v-if="record.targetUserId"> · 用户 {{ record.targetUserId }}</template>
</div>
<div v-if="record.minVersionCode || record.maxVersionCode" class="subtle">
版本 {{ record.minVersionCode || 1 }} - {{ record.maxVersionCode || '不限' }}
</div>
</template>
<template v-else-if="column.key === 'state'">
<a-tag :color="state(record).color">{{ state(record).label }}</a-tag>
</template>
<template v-else-if="column.key === 'deliveries'">
<a-space wrap size="small">
<a-tag color="success">成功 {{ record.deliveries.accepted }}</a-tag>
<a-tag v-if="record.deliveries.queued + record.deliveries.sending">处理中 {{ record.deliveries.queued + record.deliveries.sending }}</a-tag>
<a-tag v-if="record.deliveries.failed" color="error">失败 {{ record.deliveries.failed }}</a-tag>
<a-tag v-if="record.deliveries.skipped">跳过 {{ record.deliveries.skipped }}</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'time'">
<div>{{ record.scheduledAt ? '计划 ' + formatTime(record.scheduledAt) : formatTime(record.createdAt) }}</div>
<div v-if="record.completedAt" class="subtle">完成 {{ formatTime(record.completedAt) }}</div>
</template>
<template v-else-if="column.key === 'actions'">
<a-space size="small">
<a-button v-if="isEditable(record)" size="small" @click="openEdit(record)"><EditOutlined />编辑</a-button>
<a-popconfirm v-if="record.state === 'draft' || record.state === 'scheduled'" title="立即开始投递这条推送?" @confirm="sendNow(record)">
<a-button size="small" type="primary"><SendOutlined />发送</a-button>
</a-popconfirm>
<a-popconfirm v-if="isCancellable(record)" title="确定取消该推送活动?" @confirm="cancelCampaign(record)">
<a-button size="small" danger>取消</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="devices" tab="注册设备">
<div class="toolbar">
<a-input-search v-model:value="deviceSearch" placeholder="用户名或安装 ID" style="width: 280px" @search="loadDevices" />
<a-button size="small" @click="loadDevices"><ReloadOutlined />刷新</a-button>
</div>
<a-table :columns="deviceColumns" :data-source="devices" :loading="deviceLoading" row-key="id" size="small" :scroll="{ x: 1050 }" :pagination="false">
<template #bodyCell="{ column, record }: { column: { key: string }; record: PushDevice }">
<template v-if="column.key === 'device'">
<div class="mono">#{{ record.id }} · ...{{ record.tokenSuffix }}</div>
<div class="subtle mono">{{ record.packageName }}</div>
</template>
<template v-else-if="column.key === 'user'">
<div>{{ record.username }}</div>
<div class="subtle">用户 {{ record.userId }}</div>
</template>
<template v-else-if="column.key === 'provider'">
{{ providerLabels[record.provider] || record.provider }}
</template>
<template v-else-if="column.key === 'app'">
<div>{{ record.appVersion }} ({{ record.versionCode }})</div>
<a-tag>{{ record.flavor === 'production' ? '正式' : '内测' }}</a-tag>
</template>
<template v-else-if="column.key === 'state'">
<a-badge :status="record.isActive && record.notificationsAllowed ? 'success' : 'default'" :text="record.isActive && record.notificationsAllowed ? '可投递' : '不可投递'" />
<div v-if="record.disabledReason" class="subtle">{{ record.disabledReason }}</div>
</template>
<template v-else-if="column.key === 'time'">{{ formatTime(record.lastSeenAt) }}</template>
<template v-else-if="column.key === 'actions'">
<a-button size="small" :disabled="!record.isActive || !record.notificationsAllowed" @click="openTest(record)"><SendOutlined />测试</a-button>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
<a-modal v-model:open="editorOpen" :title="editingId ? '编辑推送' : '新建推送'" :width="760" :footer="null" :mask-closable="false">
<a-form layout="vertical">
<a-row :gutter="12">
<a-col :span="12"><a-form-item label="标题" required><a-input v-model:value="form.title" :maxlength="80" show-count /></a-form-item></a-col>
<a-col :span="6"><a-form-item label="分类" required><a-select v-model:value="form.category"><a-select-option v-for="(label, value) in categoryLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
<a-col :span="6"><a-form-item label="有效期"><a-input-number v-model:value="form.ttlSeconds" :min="60" :max="604800" style="width: 100%" addon-after="" /></a-form-item></a-col>
</a-row>
<a-form-item label="正文" required><a-textarea v-model:value="form.body" :maxlength="240" show-count :rows="3" /></a-form-item>
<a-row :gutter="12">
<a-col :span="8"><a-form-item label="点击动作"><a-select v-model:value="form.action"><a-select-option v-for="(label, value) in actionLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
<a-col :span="8"><a-form-item label="关联对象 ID"><a-input v-model:value="form.entityId" :disabled="form.action === 'none' || form.action === 'home'" /></a-form-item></a-col>
<a-col :span="8"><a-form-item label="环境"><a-segmented v-model:value="form.flavor" block :options="[{ label: '正式', value: 'production' }, { label: '内测', value: 'internal' }]" /></a-form-item></a-col>
</a-row>
<a-divider orientation="left">目标范围</a-divider>
<a-row :gutter="12">
<a-col :span="8"><a-form-item label="厂商"><a-select v-model:value="form.provider" allow-clear placeholder="全部厂商"><a-select-option v-for="(label, value) in providerLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
<a-col :span="8"><a-form-item label="指定用户 ID"><a-input-number v-model:value="form.targetUserId" :min="1" style="width: 100%" /></a-form-item></a-col>
<a-col :span="4"><a-form-item label="最低版本"><a-input-number v-model:value="form.minVersionCode" :min="1" style="width: 100%" /></a-form-item></a-col>
<a-col :span="4"><a-form-item label="最高版本"><a-input-number v-model:value="form.maxVersionCode" :min="1" style="width: 100%" /></a-form-item></a-col>
</a-row>
<div class="estimate-row">
<a-button :loading="estimating" :disabled="!canEstimate" @click="estimateTargets">估算目标设备</a-button>
<span v-if="estimate !== null">当前条件下预计投递 <strong>{{ estimate }}</strong> 台设备</span>
</div>
<a-form-item label="定时发送时间">
<a-date-picker v-model:value="scheduleAt" show-time format="YYYY-MM-DD HH:mm" :disabled-date="(date: Dayjs) => date.isBefore(dayjs().startOf('day'))" style="width: 100%" placeholder="仅在选择定时发送时使用" />
</a-form-item>
<div class="modal-actions">
<a-button @click="editorOpen = false">关闭</a-button>
<a-button :loading="editorSaving" @click="submitCampaign('draft')">保存草稿</a-button>
<a-button :loading="editorSaving" :disabled="!scheduleAt" @click="submitCampaign('scheduled')">定时发送</a-button>
<a-popconfirm title="确认立即投递" @confirm="submitCampaign('now')">
<a-button type="primary" :loading="editorSaving"><SendOutlined />立即发送</a-button>
</a-popconfirm>
</div>
</a-form>
</a-modal>
<a-modal v-model:open="testOpen" title="单设备测试" :confirm-loading="testSaving" ok-text="发送测试" cancel-text="取消" @ok="submitTest">
<a-form layout="vertical">
<a-form-item label="设备" required>
<a-select v-model:value="testForm.deviceId" show-search option-filter-prop="label" placeholder="选择可投递设备">
<a-select-option v-for="device in devices.filter(item => item.isActive && item.notificationsAllowed)" :key="device.id" :value="device.id" :label="`${device.username} ${device.provider} ${device.id}`">
{{ device.username }} · {{ providerLabels[device.provider] }} · #{{ device.id }} · {{ device.appVersion }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="标题" required><a-input v-model:value="testForm.title" :maxlength="80" show-count /></a-form-item>
<a-form-item label="正文" required><a-textarea v-model:value="testForm.body" :maxlength="240" show-count :rows="3" /></a-form-item>
<a-row :gutter="12">
<a-col :span="12"><a-form-item label="分类"><a-select v-model:value="testForm.category"><a-select-option v-for="(label, value) in categoryLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
<a-col :span="12"><a-form-item label="点击动作"><a-select v-model:value="testForm.action"><a-select-option v-for="(label, value) in actionLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
</a-row>
<a-form-item label="关联对象 ID"><a-input v-model:value="testForm.entityId" :disabled="testForm.action === 'none' || testForm.action === 'home'" /></a-form-item>
</a-form>
</a-modal>
</template>
<style scoped>
.page-header,
.toolbar,
.modal-actions,
.estimate-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-header { margin-bottom: 16px; }
.page-header h2 { margin: 0; }
.toolbar { margin-bottom: 12px; }
.subtle { color: #8c8c8c; font-size: 12px; }
.campaign-title { font-weight: 600; margin-bottom: 3px; }
.campaign-body { color: #595959; font-size: 12px; margin-bottom: 6px; white-space: pre-wrap; }
.action-label { color: #8c8c8c; font-size: 12px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.estimate-row { justify-content: flex-start; min-height: 32px; margin-bottom: 16px; }
.modal-actions { justify-content: flex-end; padding-top: 4px; }
@media (max-width: 760px) {
.page-header { align-items: flex-start; flex-direction: column; }
}
</style>
@@ -41,9 +41,13 @@ public sealed class ApiFixture : IAsyncLifetime
Environment.SetEnvironmentVariable(
"Jwt__Secret",
"test-only-jwt-secret-at-least-thirty-two-characters");
Environment.SetEnvironmentVariable(
"Admin__Key",
"test-only-admin-key-at-least-24-characters");
Environment.SetEnvironmentVariable(
"Admin__Key",
"test-only-admin-key-at-least-24-characters");
Environment.SetEnvironmentVariable(
"Push__TokenEncryptionKey",
Convert.ToBase64String(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray()));
Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", "1000");
Factory = new WebApplicationFactory<Program>().WithWebHostBuilder(
builder =>
@@ -65,8 +69,10 @@ public sealed class ApiFixture : IAsyncLifetime
Factory?.Dispose();
await _database.DisposeAsync();
Environment.SetEnvironmentVariable("ConnectionStrings__Default", null);
Environment.SetEnvironmentVariable("Jwt__Secret", null);
Environment.SetEnvironmentVariable("Admin__Key", null);
Environment.SetEnvironmentVariable("Jwt__Secret", null);
Environment.SetEnvironmentVariable("Admin__Key", null);
Environment.SetEnvironmentVariable("Push__TokenEncryptionKey", null);
Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", null);
}
public async Task<HttpClient> RegisterAsync(string username)
@@ -395,7 +401,7 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
[Fact]
public async Task RecognitionBatch_PreservesThreeConsecutiveTransfers_AndIsIdempotent()
{
using var client = await fixture.RegisterAsync("recognition_batch_three_transfers");
using var client = await fixture.RegisterAsync("recognition_batch_transfers");
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var categories = await client.GetFromJsonAsync<JsonElement>(
@@ -0,0 +1,309 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace MiaoJiZhang.Api.Tests;
[Collection(ApiCollection.Name)]
public sealed class PushIntegrationTests(ApiFixture fixture)
{
private const string AdminKey = "test-only-admin-key-at-least-24-characters";
[Fact]
public async Task Preferences_DefaultOff_AndPersistAllCategories()
{
using var client = await fixture.RegisterAsync("push_preferences");
var defaults = await client.GetFromJsonAsync<JsonElement>("/api/push/preferences");
Assert.False(defaults.GetProperty("system").GetBoolean());
Assert.False(defaults.GetProperty("budget").GetBoolean());
Assert.False(defaults.GetProperty("operations").GetBoolean());
var update = await client.PutAsJsonAsync(
"/api/push/preferences",
new { system = true, budget = false, operations = true });
update.EnsureSuccessStatusCode();
var persisted = await client.GetFromJsonAsync<JsonElement>("/api/push/preferences");
Assert.True(persisted.GetProperty("system").GetBoolean());
Assert.False(persisted.GetProperty("budget").GetBoolean());
Assert.True(persisted.GetProperty("operations").GetBoolean());
await using var scope = fixture.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var userId = await db.Users.Where(user => user.Username == "push_preferences")
.Select(user => user.Id).SingleAsync();
var preferences = await db.UserPushPreferences.Where(item => item.UserId == userId)
.ToDictionaryAsync(item => item.Category, item => item.IsEnabled);
Assert.Equal(3, preferences.Count);
Assert.True(preferences[PushCategories.System]);
Assert.False(preferences[PushCategories.Budget]);
Assert.True(preferences[PushCategories.Operations]);
}
[Fact]
public async Task DeviceRegistration_RebindsInstallation_AndRotatesAnonymousUnbindToken()
{
using var firstUser = await fixture.RegisterAsync("push_device_first");
using var secondUser = await fixture.RegisterAsync("push_device_second");
await EnableSystemAsync(firstUser);
await EnableSystemAsync(secondUser);
var installationId = Guid.NewGuid().ToString();
var firstRegistration = await RegisterDeviceAsync(
firstUser,
installationId,
"first-device-token");
var firstPayload = await firstRegistration.Content.ReadFromJsonAsync<JsonElement>();
var deviceId = firstPayload.GetProperty("deviceId").GetInt64();
var oldUnbindToken = firstPayload.GetProperty("unbindToken").GetString()!;
var secondRegistration = await RegisterDeviceAsync(
secondUser,
installationId,
"second-device-token");
var secondPayload = await secondRegistration.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(deviceId, secondPayload.GetProperty("deviceId").GetInt64());
var newUnbindToken = secondPayload.GetProperty("unbindToken").GetString()!;
Assert.NotEqual(oldUnbindToken, newUnbindToken);
using var anonymous = fixture.Factory.CreateClient();
await DeleteWithUnbindToken(anonymous, installationId, oldUnbindToken);
Assert.True(await DeviceExists(deviceId));
var unauthenticated = await anonymous.DeleteAsync($"/api/push/devices/{installationId}");
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
Assert.True(await DeviceExists(deviceId));
var removed = await DeleteWithUnbindToken(anonymous, installationId, newUnbindToken);
Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode);
Assert.False(await DeviceExists(deviceId));
}
[Fact]
public async Task BudgetPush_CreatesMessagesOnlyWhenCrossingEnabledThresholds()
{
using var client = await fixture.RegisterAsync("push_budget_crossings");
var preference = await client.PutAsJsonAsync(
"/api/push/preferences",
new { system = false, budget = true, operations = false });
preference.EnsureSuccessStatusCode();
var (userId, ledgerId, categoryId) = await FinanceContext(client);
await PutBudget(client, ledgerId, categoryId, 100);
await CreateExpense(client, ledgerId, categoryId, 79, "budget-crossing-79");
Assert.Equal((0, 0), await BudgetCounts(userId));
await CreateExpense(client, ledgerId, categoryId, 1, "budget-crossing-80");
Assert.Equal((1, 1), await BudgetCounts(userId));
await CreateExpense(client, ledgerId, categoryId, 20, "budget-crossing-100");
Assert.Equal((2, 2), await BudgetCounts(userId));
await using var scope = fixture.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var messages = await db.PushMessages.Where(item => item.TargetUserId == userId && item.Source == "budget")
.OrderBy(item => item.Id).ToListAsync();
Assert.Equal("预算接近上限", messages[0].Title);
Assert.Equal("预算已达到上限", messages[1].Title);
Assert.All(messages, item => Assert.Equal(PushActions.Budget, item.Action));
}
[Fact]
public async Task BudgetPush_OneStepCrossingAggregatesMessage_AndDisabledPreferenceDoesNotBackfill()
{
using var enabled = await fixture.RegisterAsync("push_budget_one_step");
var enabledPreference = await enabled.PutAsJsonAsync(
"/api/push/preferences",
new { system = false, budget = true, operations = false });
enabledPreference.EnsureSuccessStatusCode();
var (enabledUserId, enabledLedgerId, enabledCategoryId) = await FinanceContext(enabled);
await PutBudget(enabled, enabledLedgerId, enabledCategoryId, 100);
await CreateExpense(enabled, enabledLedgerId, enabledCategoryId, 100, "budget-one-step");
Assert.Equal((2, 1), await BudgetCounts(enabledUserId));
using var disabled = await fixture.RegisterAsync("push_budget_disabled");
var (disabledUserId, disabledLedgerId, disabledCategoryId) = await FinanceContext(disabled);
await PutBudget(disabled, disabledLedgerId, disabledCategoryId, 100);
await CreateExpense(disabled, disabledLedgerId, disabledCategoryId, 100, "budget-disabled");
Assert.Equal((2, 0), await BudgetCounts(disabledUserId));
var disabledPreference = await disabled.PutAsJsonAsync(
"/api/push/preferences",
new { system = false, budget = true, operations = false });
disabledPreference.EnsureSuccessStatusCode();
await CreateExpense(disabled, disabledLedgerId, disabledCategoryId, 1, "budget-no-backfill");
Assert.Equal((2, 0), await BudgetCounts(disabledUserId));
}
[Fact]
public async Task AdminCampaign_ValidatesEstimatesSchedulesCancelsAndQueues()
{
using var user = await fixture.RegisterAsync("push_campaign_target");
await EnableSystemAsync(user);
var profile = await user.GetFromJsonAsync<JsonElement>("/api/users/me");
var userId = profile.GetProperty("userId").GetInt64();
await RegisterDeviceAsync(user, Guid.NewGuid().ToString(), "campaign-device-token");
using var admin = fixture.Factory.CreateClient();
admin.DefaultRequestHeaders.Add("X-Admin-Key", AdminKey);
var request = new
{
title = "系统维护通知",
body = "今晚 23:00 将进行短时维护",
category = "system",
action = "home",
flavor = "production",
provider = "xiaomi",
targetUserId = userId,
minVersionCode = 1,
maxVersionCode = 99999999,
ttlSeconds = 3600,
};
var estimate = await admin.PostAsJsonAsync("/api/admin/push/campaigns/estimate", request);
estimate.EnsureSuccessStatusCode();
Assert.Equal(
1,
(await estimate.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("devices").GetInt32());
var invalid = await admin.PostAsJsonAsync(
"/api/admin/push/campaigns",
new { request.title, request.body, category = "unknown", request.action, request.flavor });
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
var created = await admin.PostAsJsonAsync("/api/admin/push/campaigns", request);
created.EnsureSuccessStatusCode();
var campaign = await created.Content.ReadFromJsonAsync<JsonElement>();
var campaignId = campaign.GetProperty("id").GetInt64();
Assert.Equal(PushMessageStates.Draft, campaign.GetProperty("state").GetString());
var scheduled = await admin.PostAsJsonAsync(
$"/api/admin/push/campaigns/{campaignId}/send",
new { scheduledAt = DateTime.UtcNow.AddHours(1) });
scheduled.EnsureSuccessStatusCode();
Assert.Equal(
PushMessageStates.Scheduled,
(await scheduled.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("state").GetString());
var cancelled = await admin.PostAsync($"/api/admin/push/campaigns/{campaignId}/cancel", null);
cancelled.EnsureSuccessStatusCode();
var sendCancelled = await admin.PostAsJsonAsync(
$"/api/admin/push/campaigns/{campaignId}/send",
new { scheduledAt = (DateTime?)null });
Assert.Equal(HttpStatusCode.Conflict, sendCancelled.StatusCode);
var immediateDraft = await admin.PostAsJsonAsync(
"/api/admin/push/campaigns",
new { request.title, body = "立即发送", request.category, request.action, request.flavor, request.targetUserId });
immediateDraft.EnsureSuccessStatusCode();
var immediateId = (await immediateDraft.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetInt64();
var queued = await admin.PostAsJsonAsync(
$"/api/admin/push/campaigns/{immediateId}/send",
new { scheduledAt = (DateTime?)null });
queued.EnsureSuccessStatusCode();
Assert.Equal(
PushMessageStates.Queued,
(await queued.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("state").GetString());
}
private static async Task EnableSystemAsync(HttpClient client)
{
var response = await client.PutAsJsonAsync(
"/api/push/preferences",
new { system = true, budget = false, operations = false });
response.EnsureSuccessStatusCode();
}
private static async Task<HttpResponseMessage> RegisterDeviceAsync(
HttpClient client,
string installationId,
string token)
{
var response = await client.PutAsJsonAsync(
$"/api/push/devices/{installationId}",
new
{
provider = "xiaomi",
token,
packageName = "com.nx.miaoji",
flavor = "production",
appVersion = "20260725-test",
versionCode = 20260725,
notificationsAllowed = true,
});
response.EnsureSuccessStatusCode();
return response;
}
private static async Task<HttpResponseMessage> DeleteWithUnbindToken(
HttpClient client,
string installationId,
string token)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/push/devices/{installationId}");
request.Headers.Add("X-Push-Unbind-Token", token);
return await client.SendAsync(request);
}
private async Task<bool> DeviceExists(long id)
{
await using var scope = fixture.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.PushDevices.AnyAsync(item => item.Id == id);
}
private static async Task<(long UserId, long LedgerId, long CategoryId)> FinanceContext(HttpClient client)
{
var profile = await client.GetFromJsonAsync<JsonElement>("/api/users/me");
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
var categories = await client.GetFromJsonAsync<JsonElement>("/api/categories?type=expense");
return (
profile.GetProperty("userId").GetInt64(),
ledgers[0].GetProperty("id").GetInt64(),
categories[0].GetProperty("id").GetInt64());
}
private static async Task PutBudget(HttpClient client, long ledgerId, long categoryId, decimal amount)
{
var response = await client.PutAsJsonAsync(
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
new { categoryId, amount, recurring = false });
response.EnsureSuccessStatusCode();
}
private static async Task CreateExpense(
HttpClient client,
long ledgerId,
long categoryId,
decimal amount,
string clientRequestId)
{
var response = await client.PostAsJsonAsync(
"/api/transactions",
new
{
ledgerId,
categoryId,
type = "expense",
amount,
occurredAt = "2026-07-25T12:00:00+08:00",
source = "manual",
clientRequestId,
});
response.EnsureSuccessStatusCode();
}
private async Task<(int Receipts, int Messages)> BudgetCounts(long userId)
{
await using var scope = fixture.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return (
await db.BudgetNotificationReceipts.CountAsync(item => item.UserId == userId),
await db.PushMessages.CountAsync(item => item.TargetUserId == userId && item.Source == "budget"));
}
}
@@ -0,0 +1,154 @@
using System.Net;
using System.Net.Http.Headers;
using MiaoJiZhang.Api.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace MiaoJiZhang.Api.Tests;
public sealed class PushProviderTests
{
private static readonly PushEnvelope Message = new(
"message-1",
"测试标题",
"测试正文",
"system",
"home",
null,
3600);
[Fact]
public async Task Xiaomi_Http200BusinessFailure_IsNotAccepted()
{
var factory = new StubHttpClientFactory(_ => Json(
"""{"result":"error","code":70000003,"description":"invalid registration_id"}"""));
var provider = Provider("xiaomi", factory, new Dictionary<string, string?>
{
["Push:Providers:xiaomi:production:Enabled"] = "true",
["Push:Providers:xiaomi:production:AppSecret"] = "server-secret",
});
var result = await provider.SendAsync(
"production",
"com.nx.miaoji",
"invalid-token",
Message,
CancellationToken.None);
Assert.False(result.Accepted);
Assert.True(result.InvalidToken);
Assert.Equal("provider_error", result.ErrorCode);
}
[Fact]
public async Task Xiaomi_Http200Success_IsAccepted()
{
var factory = new StubHttpClientFactory(_ => Json(
"""{"result":"ok","code":0,"data":{"id":"xiaomi-message"}}"""));
var provider = Provider("xiaomi", factory, new Dictionary<string, string?>
{
["Push:Providers:xiaomi:production:Enabled"] = "true",
["Push:Providers:xiaomi:production:AppSecret"] = "server-secret",
});
var result = await provider.SendAsync(
"production",
"com.nx.miaoji",
"valid-token",
Message,
CancellationToken.None);
Assert.True(result.Accepted);
Assert.False(result.Retryable);
Assert.False(result.InvalidToken);
}
[Fact]
public async Task Huawei_AccessTokens_AreCachedPerFlavor()
{
var authCalls = new Dictionary<string, int>();
var sendTokens = new Dictionary<string, List<string>>();
var factory = new StubHttpClientFactory(request =>
{
var path = request.RequestUri!.AbsolutePath;
var flavor = path.Contains("production", StringComparison.Ordinal)
? "production"
: "internal";
if (path.EndsWith("/auth", StringComparison.Ordinal))
{
authCalls[flavor] = authCalls.GetValueOrDefault(flavor) + 1;
return Json($$"""{"access_token":"{{flavor}}-token","expires_in":3600}""");
}
sendTokens.TryAdd(flavor, []);
sendTokens[flavor].Add(request.Headers.Authorization?.Parameter ?? "");
return Json("""{"code":"80000000","requestId":"huawei-message"}""");
});
var provider = Provider("huawei", factory, new Dictionary<string, string?>
{
["Push:Providers:huawei:production:Enabled"] = "true",
["Push:Providers:huawei:production:AppId"] = "production-app",
["Push:Providers:huawei:production:AppSecret"] = "production-secret",
["Push:Providers:huawei:production:AuthUrl"] = "https://push.test/production/auth",
["Push:Providers:huawei:production:SendUrl"] = "https://push.test/production/send",
["Push:Providers:huawei:internal:Enabled"] = "true",
["Push:Providers:huawei:internal:AppId"] = "internal-app",
["Push:Providers:huawei:internal:AppSecret"] = "internal-secret",
["Push:Providers:huawei:internal:AuthUrl"] = "https://push.test/internal/auth",
["Push:Providers:huawei:internal:SendUrl"] = "https://push.test/internal/send",
});
Assert.True((await provider.SendAsync(
"production", "com.nx.miaoji", "token-1", Message, CancellationToken.None)).Accepted);
Assert.True((await provider.SendAsync(
"internal", "com.nx.miaoji.internal", "token-2", Message, CancellationToken.None)).Accepted);
Assert.True((await provider.SendAsync(
"production", "com.nx.miaoji", "token-3", Message, CancellationToken.None)).Accepted);
Assert.Equal(1, authCalls["production"]);
Assert.Equal(1, authCalls["internal"]);
Assert.Equal(["production-token", "production-token"], sendTokens["production"]);
Assert.Equal(["internal-token"], sendTokens["internal"]);
}
private static OfficialPushProvider Provider(
string name,
IHttpClientFactory factory,
Dictionary<string, string?> values)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
return new OfficialPushProvider(
name,
configuration,
factory,
NullLogger<OfficialPushProvider>.Instance);
}
private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK)
{
Content = new StringContent(body),
};
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient client;
public StubHttpClientFactory(Func<HttpRequestMessage, HttpResponseMessage> response)
{
client = new HttpClient(new StubHandler(response));
}
public HttpClient CreateClient(string name) => client;
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> response)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(response(request));
}
}
@@ -0,0 +1,43 @@
namespace MiaoJiZhang.Api.Contracts;
public record PushPreferencesResponse(bool System, bool Budget, bool Operations);
public record UpdatePushPreferencesRequest(bool System, bool Budget, bool Operations);
public record RegisterPushDeviceRequest(
string Provider,
string Token,
string PackageName,
string Flavor,
string AppVersion,
int VersionCode,
bool NotificationsAllowed);
public record PushDeviceRegistrationResponse(
long DeviceId,
string InstallationId,
string Provider,
bool Active,
string UnbindToken);
public record CreatePushCampaignRequest(
string Title,
string Body,
string Category,
string Action = "none",
string? EntityId = null,
string Flavor = "production",
string? Provider = null,
int? MinVersionCode = null,
int? MaxVersionCode = null,
long? TargetUserId = null,
int? TtlSeconds = null);
public record SchedulePushCampaignRequest(DateTime? ScheduledAt = null);
public record TestPushRequest(
long DeviceId,
string Title,
string Body,
string Category = "system",
string Action = "none",
string? EntityId = null);
@@ -0,0 +1,326 @@
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[AdminAuth]
[Route("api/admin/push")]
public class AdminPushController(
AppDbContext db,
PushProviderRegistry providers,
IConfiguration configuration) : ControllerBase
{
[HttpGet("campaigns")]
public async Task<IActionResult> Campaigns(
[FromQuery] int page = 1,
[FromQuery] int limit = 20,
CancellationToken ct = default)
{
page = Math.Max(1, page);
limit = Math.Clamp(limit, 1, 100);
var query = db.PushMessages.AsNoTracking().Where(message => message.Source == "admin");
var total = await query.CountAsync(ct);
var messages = await query.OrderByDescending(message => message.CreatedAt)
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
var ids = messages.Select(message => message.Id).ToList();
var counts = await db.PushDeliveries.Where(delivery => ids.Contains(delivery.PushMessageId))
.GroupBy(delivery => new { delivery.PushMessageId, delivery.State })
.Select(group => new { group.Key.PushMessageId, group.Key.State, Count = group.Count() })
.ToListAsync(ct);
return Ok(new
{
total,
page,
list = messages.Select(message => ToDto(message, counts
.Where(item => item.PushMessageId == message.Id)
.ToDictionary(item => item.State, item => item.Count))),
});
}
[HttpPost("campaigns/estimate")]
public async Task<IActionResult> Estimate(CreatePushCampaignRequest request, CancellationToken ct)
{
var error = Validate(request);
if (error is not null) return BadRequest(error);
var count = await EligibleDevices(request).CountAsync(ct);
return Ok(new { devices = count });
}
[HttpPost("campaigns")]
public async Task<IActionResult> Create(CreatePushCampaignRequest request, CancellationToken ct)
{
var error = Validate(request);
if (error is not null) return BadRequest(error);
if (request.TargetUserId.HasValue &&
!await db.Users.AnyAsync(user => user.Id == request.TargetUserId.Value, ct))
return BadRequest(new ApiError("PUSH_TARGET_INVALID", "目标用户不存在"));
var now = DateTime.UtcNow;
var message = Map(request, new PushMessage
{
PublicId = Guid.NewGuid().ToString(),
Source = "admin",
State = PushMessageStates.Draft,
CreatedAt = now,
}, now);
db.PushMessages.Add(message);
await db.SaveChangesAsync(ct);
return Ok(ToDto(message, new Dictionary<string, int>()));
}
[HttpPut("campaigns/{id:long}")]
public async Task<IActionResult> Update(long id, CreatePushCampaignRequest request, CancellationToken ct)
{
var error = Validate(request);
if (error is not null) return BadRequest(error);
if (request.TargetUserId.HasValue &&
!await db.Users.AnyAsync(user => user.Id == request.TargetUserId.Value, ct))
return BadRequest(new ApiError("PUSH_TARGET_INVALID", "目标用户不存在"));
var message = await db.PushMessages.FirstOrDefaultAsync(item => item.Id == id && item.Source == "admin", ct);
if (message is null) return NotFound();
if (message.State is not (PushMessageStates.Draft or PushMessageStates.Scheduled))
return Conflict(new ApiError("PUSH_CAMPAIGN_LOCKED", "推送已开始发送,不能再编辑"));
Map(request, message, DateTime.UtcNow);
await db.SaveChangesAsync(ct);
return Ok(ToDto(message, new Dictionary<string, int>()));
}
[HttpPost("campaigns/{id:long}/send")]
public async Task<IActionResult> Send(
long id,
SchedulePushCampaignRequest request,
CancellationToken ct)
{
var message = await db.PushMessages.FirstOrDefaultAsync(item => item.Id == id && item.Source == "admin", ct);
if (message is null) return NotFound();
if (message.State is not (PushMessageStates.Draft or PushMessageStates.Scheduled))
return Conflict(new ApiError("PUSH_CAMPAIGN_LOCKED", "推送已开始发送或已经结束"));
var now = DateTime.UtcNow;
var scheduledAt = request.ScheduledAt?.ToUniversalTime();
message.ScheduledAt = scheduledAt;
message.State = scheduledAt.HasValue && scheduledAt.Value > now.AddSeconds(5)
? PushMessageStates.Scheduled
: PushMessageStates.Queued;
message.UpdatedAt = now;
await db.SaveChangesAsync(ct);
return Ok(ToDto(message, new Dictionary<string, int>()));
}
[HttpPost("campaigns/{id:long}/cancel")]
public async Task<IActionResult> Cancel(long id, CancellationToken ct)
{
var now = DateTime.UtcNow;
var cancelled = await db.PushMessages
.Where(message => message.Id == id && message.Source == "admin" &&
(message.State == PushMessageStates.Draft ||
message.State == PushMessageStates.Scheduled ||
message.State == PushMessageStates.Queued) &&
message.StartedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(message => message.State, PushMessageStates.Cancelled)
.SetProperty(message => message.CancelledAt, now)
.SetProperty(message => message.UpdatedAt, now), ct);
if (cancelled != 1)
{
var exists = await db.PushMessages.AnyAsync(
message => message.Id == id && message.Source == "admin", ct);
return exists
? Conflict(new ApiError("PUSH_CAMPAIGN_LOCKED", "推送已经开始,不能取消"))
: NotFound();
}
var message = await db.PushMessages.AsNoTracking().FirstAsync(item => item.Id == id, ct);
return Ok(ToDto(message, new Dictionary<string, int>()));
}
[HttpGet("devices")]
public async Task<IActionResult> Devices(
[FromQuery] string? search = null,
[FromQuery] int limit = 50,
CancellationToken ct = default)
{
limit = Math.Clamp(limit, 1, 100);
var query = db.PushDevices.AsNoTracking().Include(device => device.User).AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim();
query = query.Where(device => device.User.Username.Contains(term) ||
device.InstallationId.Contains(term));
}
var devices = await query.OrderByDescending(device => device.LastSeenAt).Take(limit).ToListAsync(ct);
return Ok(devices.Select(device => new
{
device.Id,
device.UserId,
device.User.Username,
device.Provider,
device.PackageName,
device.Flavor,
device.AppVersion,
device.VersionCode,
device.NotificationsAllowed,
device.IsActive,
device.DisabledReason,
tokenSuffix = device.TokenHash[^Math.Min(8, device.TokenHash.Length)..],
device.LastSeenAt,
}));
}
[HttpPost("test")]
public async Task<IActionResult> Test(TestPushRequest request, CancellationToken ct)
{
if (request.Title.Trim().Length is < 1 or > 80 || request.Body.Trim().Length is < 1 or > 240 ||
!PushCategories.All.Contains(request.Category) || !PushActions.All.Contains(request.Action))
return BadRequest(new ApiError("PUSH_MESSAGE_INVALID", "测试推送内容或分类无效"));
var device = await db.PushDevices.FirstOrDefaultAsync(item => item.Id == request.DeviceId, ct);
if (device is null || !device.IsActive || !device.NotificationsAllowed)
return BadRequest(new ApiError("PUSH_DEVICE_INACTIVE", "测试设备不存在或当前不可投递"));
var now = DateTime.UtcNow;
var message = new PushMessage
{
PublicId = Guid.NewGuid().ToString(),
Source = "admin",
State = PushMessageStates.Queued,
Category = request.Category.ToLowerInvariant(),
Title = request.Title.Trim(),
Body = request.Body.Trim(),
Action = request.Action.ToLowerInvariant(),
EntityId = request.EntityId?.Trim(),
TargetUserId = device.UserId,
Flavor = device.Flavor,
ProviderFilter = device.Provider,
TtlSeconds = DefaultTtl(request.Category),
IsTest = true,
TestDeviceId = device.Id,
CreatedAt = now,
UpdatedAt = now,
};
db.PushMessages.Add(message);
await db.SaveChangesAsync(ct);
return Ok(new { message.Id, message.PublicId, message.State });
}
[HttpGet("health")]
public IActionResult Health()
{
var flavors = new[] { "production", "internal" };
return Ok(new
{
enabled = configuration.GetValue<bool>("Push:Enabled"),
tokenEncryptionConfigured = !string.IsNullOrWhiteSpace(configuration["Push:TokenEncryptionKey"]),
providers = providers.All.Select(provider => new
{
provider = provider.Provider,
environments = flavors.Select(flavor => new
{
flavor,
enabled = provider.IsEnabled(flavor),
errors = provider.ConfigurationErrors(flavor),
}),
}),
});
}
private IQueryable<PushDevice> EligibleDevices(CreatePushCampaignRequest request)
{
var category = request.Category.Trim().ToLowerInvariant();
var query = db.PushDevices.Where(device =>
device.IsActive && device.NotificationsAllowed &&
!device.User.IsBanned && device.User.AccountClosureScheduledAt == null &&
db.UserPushPreferences.Any(preference => preference.UserId == device.UserId &&
preference.Category == category && preference.IsEnabled) &&
device.Flavor == request.Flavor.ToLowerInvariant());
if (request.TargetUserId.HasValue)
query = query.Where(device => device.UserId == request.TargetUserId.Value);
if (!string.IsNullOrWhiteSpace(request.Provider))
query = query.Where(device => device.Provider == request.Provider.ToLowerInvariant());
if (request.MinVersionCode.HasValue)
query = query.Where(device => device.VersionCode >= request.MinVersionCode.Value);
if (request.MaxVersionCode.HasValue)
query = query.Where(device => device.VersionCode <= request.MaxVersionCode.Value);
return query;
}
private static PushMessage Map(CreatePushCampaignRequest request, PushMessage message, DateTime now)
{
message.Title = request.Title.Trim();
message.Body = request.Body.Trim();
message.Category = request.Category.Trim().ToLowerInvariant();
message.Action = request.Action.Trim().ToLowerInvariant();
message.EntityId = string.IsNullOrWhiteSpace(request.EntityId) ? null : request.EntityId.Trim();
message.Flavor = request.Flavor.Trim().ToLowerInvariant();
message.ProviderFilter = string.IsNullOrWhiteSpace(request.Provider)
? null
: request.Provider.Trim().ToLowerInvariant();
message.MinVersionCode = request.MinVersionCode;
message.MaxVersionCode = request.MaxVersionCode;
message.TargetUserId = request.TargetUserId;
message.TtlSeconds = request.TtlSeconds ?? DefaultTtl(message.Category);
message.UpdatedAt = now;
return message;
}
private static ApiError? Validate(CreatePushCampaignRequest request)
{
if (request.Title.Trim().Length is < 1 or > 80)
return new ApiError("PUSH_TITLE_INVALID", "标题长度必须在 1 到 80 个字符之间");
if (request.Body.Trim().Length is < 1 or > 240)
return new ApiError("PUSH_BODY_INVALID", "正文长度必须在 1 到 240 个字符之间");
if (!PushCategories.All.Contains(request.Category))
return new ApiError("PUSH_CATEGORY_INVALID", "推送分类无效");
if (!PushActions.All.Contains(request.Action))
return new ApiError("PUSH_ACTION_INVALID", "点击动作无效");
if (request.Flavor is not ("production" or "internal"))
return new ApiError("PUSH_FLAVOR_INVALID", "推送环境无效");
if (!string.IsNullOrWhiteSpace(request.Provider) && !PushProviders.All.Contains(request.Provider))
return new ApiError("PUSH_PROVIDER_INVALID", "推送厂商无效");
if (request.MinVersionCode is < 1 || request.MaxVersionCode is < 1 ||
request.MinVersionCode > request.MaxVersionCode)
return new ApiError("PUSH_VERSION_RANGE_INVALID", "版本号范围无效");
if (request.TtlSeconds.HasValue && request.TtlSeconds is < 60 or > 604800)
return new ApiError("PUSH_TTL_INVALID", "消息有效期必须在 60 秒到 7 天之间");
return null;
}
private static int DefaultTtl(string category) => category.ToLowerInvariant() switch
{
PushCategories.System => 72 * 3600,
_ => 24 * 3600,
};
private static object ToDto(PushMessage message, IReadOnlyDictionary<string, int> counts) => new
{
message.Id,
message.PublicId,
message.State,
message.Title,
message.Body,
message.Category,
message.Action,
message.EntityId,
message.Flavor,
provider = message.ProviderFilter,
message.MinVersionCode,
message.MaxVersionCode,
message.TargetUserId,
message.TtlSeconds,
message.ScheduledAt,
message.CreatedAt,
message.StartedAt,
message.CompletedAt,
message.CancelledAt,
deliveries = new
{
queued = counts.GetValueOrDefault(PushDeliveryStates.Queued),
sending = counts.GetValueOrDefault(PushDeliveryStates.Sending),
accepted = counts.GetValueOrDefault(PushDeliveryStates.Accepted),
failed = counts.GetValueOrDefault(PushDeliveryStates.Failed),
skipped = counts.GetValueOrDefault(PushDeliveryStates.Skipped),
},
};
}
@@ -0,0 +1,184 @@
using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/push")]
public class PushController(AppDbContext db, PushTokenProtector tokenProtector) : ControllerBase
{
private long Uid => long.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
[HttpGet("preferences")]
public async Task<ActionResult<PushPreferencesResponse>> Preferences(CancellationToken ct)
{
var enabled = await db.UserPushPreferences
.Where(item => item.UserId == Uid && item.IsEnabled)
.Select(item => item.Category)
.ToListAsync(ct);
return Ok(new PushPreferencesResponse(
enabled.Contains(PushCategories.System),
enabled.Contains(PushCategories.Budget),
enabled.Contains(PushCategories.Operations)));
}
[HttpPut("preferences")]
public async Task<ActionResult<PushPreferencesResponse>> UpdatePreferences(
UpdatePushPreferencesRequest request,
CancellationToken ct)
{
var desired = new Dictionary<string, bool>
{
[PushCategories.System] = request.System,
[PushCategories.Budget] = request.Budget,
[PushCategories.Operations] = request.Operations,
};
var existing = await db.UserPushPreferences
.Where(item => item.UserId == Uid)
.ToDictionaryAsync(item => item.Category, ct);
var now = DateTime.UtcNow;
foreach (var (category, enabled) in desired)
{
if (!existing.TryGetValue(category, out var preference))
{
preference = new UserPushPreference
{
UserId = Uid,
Category = category,
};
db.UserPushPreferences.Add(preference);
}
preference.IsEnabled = enabled;
preference.UpdatedAt = now;
}
if (!desired.Values.Any(value => value))
{
var devices = await db.PushDevices
.Where(device => device.UserId == Uid && device.IsActive)
.ToListAsync(ct);
foreach (var device in devices)
{
device.IsActive = false;
device.DisabledReason = "all_categories_disabled";
device.UpdatedAt = now;
}
}
await db.SaveChangesAsync(ct);
return Ok(new PushPreferencesResponse(request.System, request.Budget, request.Operations));
}
[HttpPut("devices/{installationId}")]
public async Task<ActionResult<PushDeviceRegistrationResponse>> RegisterDevice(
string installationId,
RegisterPushDeviceRequest request,
CancellationToken ct)
{
var validation = ValidateDevice(installationId, request);
if (validation is not null) return validation;
if (!tokenProtector.IsConfigured)
return StatusCode(StatusCodes.Status503ServiceUnavailable,
new ApiError("PUSH_NOT_CONFIGURED", "推送服务尚未完成安全配置"));
var provider = request.Provider.Trim().ToLowerInvariant();
var token = request.Token.Trim();
var tokenHash = PushTokenProtector.Hash(token);
var duplicate = await db.PushDevices.FirstOrDefaultAsync(device =>
device.Provider == provider &&
device.PackageName == request.PackageName &&
device.TokenHash == tokenHash &&
device.InstallationId != installationId, ct);
if (duplicate is not null) db.PushDevices.Remove(duplicate);
var device = await db.PushDevices.FirstOrDefaultAsync(item =>
item.PackageName == request.PackageName &&
item.InstallationId == installationId, ct);
var now = DateTime.UtcNow;
if (device is null)
{
device = new PushDevice
{
UserId = Uid,
InstallationId = installationId,
PackageName = request.PackageName,
CreatedAt = now,
};
db.PushDevices.Add(device);
}
var unbindToken = PushTokenProtector.CreateUnbindToken();
device.UserId = Uid;
device.Provider = provider;
device.TokenCiphertext = tokenProtector.Protect(token);
device.TokenHash = tokenHash;
device.UnbindTokenHash = PushTokenProtector.Hash(unbindToken);
device.Flavor = request.Flavor.Trim().ToLowerInvariant();
device.AppVersion = request.AppVersion.Trim();
device.VersionCode = request.VersionCode;
device.NotificationsAllowed = request.NotificationsAllowed;
device.IsActive = request.NotificationsAllowed;
device.DisabledReason = request.NotificationsAllowed ? null : "notification_permission_denied";
device.UpdatedAt = now;
device.LastSeenAt = now;
await db.SaveChangesAsync(ct);
return Ok(new PushDeviceRegistrationResponse(
device.Id,
device.InstallationId,
device.Provider,
device.IsActive,
unbindToken));
}
[HttpDelete("devices/{installationId}")]
[AllowAnonymous]
public async Task<IActionResult> UnregisterDevice(
string installationId,
[FromHeader(Name = "X-Push-Unbind-Token")] string? unbindToken,
CancellationToken ct)
{
var userIdValue = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var hasUser = long.TryParse(userIdValue, out var userId);
var unbindHash = string.IsNullOrWhiteSpace(unbindToken)
? null
: PushTokenProtector.Hash(unbindToken);
var device = await db.PushDevices.FirstOrDefaultAsync(item =>
item.InstallationId == installationId &&
((hasUser && item.UserId == userId) ||
(unbindHash != null && item.UnbindTokenHash == unbindHash)), ct);
if (device is null)
return hasUser || unbindHash is not null ? NoContent() : Unauthorized();
db.PushDevices.Remove(device);
await db.SaveChangesAsync(ct);
return NoContent();
}
private ActionResult? ValidateDevice(string installationId, RegisterPushDeviceRequest request)
{
if (!Guid.TryParse(installationId, out _))
return BadRequest(new ApiError("INSTALLATION_ID_INVALID", "设备安装标识无效"));
if (!PushProviders.All.Contains(request.Provider))
return BadRequest(new ApiError("PUSH_PROVIDER_INVALID", "不支持该设备推送厂商"));
if (string.IsNullOrWhiteSpace(request.Token) || request.Token.Length > 4096)
return BadRequest(new ApiError("PUSH_TOKEN_INVALID", "推送令牌无效"));
var expectedFlavor = request.PackageName switch
{
"com.nx.miaoji" => "production",
"com.nx.miaoji.internal" => "internal",
_ => null,
};
if (expectedFlavor is null || !string.Equals(expectedFlavor, request.Flavor, StringComparison.OrdinalIgnoreCase))
return BadRequest(new ApiError("PUSH_PACKAGE_INVALID", "推送包名或环境无效"));
if (request.AppVersion.Length is < 1 or > 32 || request.VersionCode < 1)
return BadRequest(new ApiError("APP_VERSION_INVALID", "应用版本无效"));
return null;
}
}
@@ -13,7 +13,10 @@ namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/transactions")]
public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : ControllerBase
public class TransactionsController(
AppDbContext db,
LedgerResolver ledgers,
BudgetPushService budgetPush) : ControllerBase
{
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
@@ -68,13 +71,22 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
UpdatedAt = DateTime.UtcNow,
};
db.Transactions.Add(tx);
await using var writeScope = await db.Database.BeginTransactionAsync();
try
{
await db.SaveChangesAsync();
if (tx.Type == TransactionType.Expense)
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
await db.SaveChangesAsync();
}
await writeScope.CommitAsync();
return Ok(ToDto(tx, cat));
}
catch (DbUpdateException) when (clientRequestId is not null)
{
await writeScope.RollbackAsync();
// Another channel may have committed the same recognition candidate
// after the initial lookup. Resolve the unique-key race as idempotent success.
db.Entry(tx).State = EntityState.Detached;
@@ -161,6 +173,17 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
}
try
{
await db.SaveChangesAsync(ct);
var expenseChanges = mapped
.Where(item => !existing.ContainsKey(item.Transaction.ClientRequestId ?? "") &&
item.Transaction.Type == TransactionType.Expense)
.Select(item => new BudgetExpenseChange(
item.Transaction.LedgerId,
item.Transaction.CategoryId,
item.Transaction.OccurredAt,
item.Transaction.Amount))
.ToList();
await budgetPush.EvaluateAsync(Uid, expenseChanges, ct);
await db.SaveChangesAsync(ct);
await transactionScope.CommitAsync(ct);
return Ok(mapped.Select(item => new RecognitionBatchTransactionDto(
@@ -223,6 +246,10 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
message = "账单已在其他设备修改,请选择保留本地或云端版本",
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
});
var expenseChanges = new List<BudgetExpenseChange>();
if (tx.Type == TransactionType.Expense)
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, -tx.Amount));
tx.LedgerId = ledgerId.Value;
tx.CategoryId = category.Id;
tx.Category = category;
@@ -232,7 +259,14 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
tx.PaymentMethod = req.PaymentMethod?.Trim();
tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
tx.UpdatedAt = DateTime.UtcNow;
if (tx.Type == TransactionType.Expense)
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount));
await using var writeScope = await db.Database.BeginTransactionAsync();
await db.SaveChangesAsync();
await budgetPush.EvaluateAsync(Uid, expenseChanges);
await db.SaveChangesAsync();
await writeScope.CommitAsync();
return Ok(ToDto(tx, category));
}
@@ -291,7 +325,15 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
}); tx.IsDeleted = false;
tx.DeletedAt = null;
tx.UpdatedAt = DateTime.UtcNow;
await using var writeScope = await db.Database.BeginTransactionAsync();
await db.SaveChangesAsync();
if (tx.Type == TransactionType.Expense)
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
await db.SaveChangesAsync();
}
await writeScope.CommitAsync();
return Ok(ToDto(tx, tx.Category));
}
+39 -9
View File
@@ -9,9 +9,10 @@ using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddRateLimiter(options =>
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var authPermitLimit = Math.Max(1, builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 10));
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context =>
@@ -19,7 +20,7 @@ builder.Services.AddRateLimiter(options =>
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
@@ -50,13 +51,32 @@ 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.AddScoped<BudgetPushService>();
builder.Services.AddSingleton<PushTokenProtector>();
builder.Services.AddScoped<AiPermissionFilter>();
builder.Services.AddHttpClient("LlmClient");
builder.Services.AddHttpClient("PushProviders", client =>
{
client.Timeout = TimeSpan.FromSeconds(20);
});
foreach (var provider in new[]
{
"huawei", "honor", "xiaomi", "oppo", "vivo", "meizu",
})
{
builder.Services.AddSingleton<IPushProvider>(services => new OfficialPushProvider(
provider,
services.GetRequiredService<IConfiguration>(),
services.GetRequiredService<IHttpClientFactory>(),
services.GetRequiredService<ILogger<OfficialPushProvider>>()));
}
builder.Services.AddSingleton<PushProviderRegistry>();
builder.Services.AddSingleton<OpenAiVisionClient>();
builder.Services.AddSingleton<ILlmClient>(sp => sp.GetRequiredService<OpenAiVisionClient>());
builder.Services.AddHostedService<RecycleBinCleanupService>();
builder.Services.AddScoped<AccountDataEraser>();
builder.Services.AddHostedService<AccountClosureCleanupService>();
builder.Services.AddHostedService<AccountClosureCleanupService>();
builder.Services.AddHostedService<PushDispatchService>();
var conn = builder.Configuration.GetConnectionString("Default");
if (string.IsNullOrWhiteSpace(conn))
@@ -64,9 +84,19 @@ if (string.IsNullOrWhiteSpace(conn))
var jwtSecret = builder.Configuration["Jwt:Secret"];
if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret.Length < 32)
throw new InvalidOperationException("必须通过 Jwt__Secret 配置至少 32 位的 JWT 密钥");
var adminKey = builder.Configuration["Admin:Key"];
var adminKey = builder.Configuration["Admin:Key"];
if (string.IsNullOrWhiteSpace(adminKey) || adminKey.Length < 24)
throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
if (builder.Configuration.GetValue<bool>("Push:Enabled"))
{
var pushKey = builder.Configuration["Push:TokenEncryptionKey"];
byte[]? key = null;
try { key = string.IsNullOrWhiteSpace(pushKey) ? null : Convert.FromBase64String(pushKey); }
catch (FormatException) { }
if (key?.Length != 32)
throw new InvalidOperationException(
"启用推送时必须通过 Push__TokenEncryptionKey 配置 base64 编码的 32 字节密钥");
}
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseMySql(conn, ServerVersion.AutoDetect(conn)));
@@ -9,6 +9,18 @@ public class AccountDataEraser(AppDbContext db)
{
await using var transaction =
await db.Database.BeginTransactionAsync(ct);
await db.PushMessages
.Where(message => message.TargetUserId == userId)
.ExecuteDeleteAsync(ct);
await db.PushDevices
.Where(device => device.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.UserPushPreferences
.Where(preference => preference.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.BudgetNotificationReceipts
.Where(receipt => receipt.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.ChatMessages
.Where(message => message.UserId == userId)
.ExecuteDeleteAsync(ct);
@@ -14,6 +14,7 @@ public record AgentTurnResult(
public class AgentService(
AppDbContext db,
ILlmClient llm,
BudgetPushService budgetPush,
ILogger<AgentService> logger)
{
private static readonly JsonSerializerOptions JsonOptions =
@@ -330,12 +331,23 @@ public class AgentService(
}
db.Transactions.AddRange(pending);
await using var writeScope = await db.Database.BeginTransactionAsync(ct);
try
{
await db.SaveChangesAsync(ct);
await budgetPush.EvaluateAsync(userId, pending
.Where(transaction => transaction.Type == TransactionType.Expense)
.Select(transaction => new BudgetExpenseChange(
transaction.LedgerId,
transaction.CategoryId,
transaction.OccurredAt,
transaction.Amount)), ct);
await db.SaveChangesAsync(ct);
await writeScope.CommitAsync(ct);
}
catch
{
await writeScope.RollbackAsync(ct);
foreach (var transaction in pending)
db.Entry(transaction).State = EntityState.Detached;
throw;
@@ -0,0 +1,142 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed record BudgetExpenseChange(
long LedgerId,
long CategoryId,
DateTime OccurredAt,
decimal Delta);
public sealed class BudgetPushService(AppDbContext db)
{
private static readonly int[] Thresholds = [80, 100];
public async Task EvaluateAsync(
long userId,
IEnumerable<BudgetExpenseChange> rawChanges,
CancellationToken ct = default)
{
var changes = rawChanges
.Where(change => change.Delta != 0)
.Select(change => new
{
Change = change,
Local = ChinaClock.ToLocal(change.OccurredAt),
})
.Select(item => new ChangeWithPeriod(
item.Change.LedgerId,
item.Change.CategoryId,
item.Local.Year * 100 + item.Local.Month,
item.Change.Delta))
.ToList();
if (changes.Count == 0) return;
var crossed = new List<CrossedBudget>();
foreach (var group in changes.GroupBy(change => new { change.LedgerId, change.Period }))
{
var period = group.Key.Period;
var year = period / 100;
var month = period % 100;
if (month is < 1 or > 12) continue;
var rows = await db.Budgets
.Where(budget => budget.UserId == userId && budget.LedgerId == group.Key.LedgerId &&
(budget.Period == period || budget.Period == 0) && budget.Amount > 0)
.ToListAsync(ct);
var budgets = rows.GroupBy(budget => budget.CategoryId)
.Select(items => items.FirstOrDefault(item => item.Period == period) ??
items.First(item => item.Period == 0))
.ToList();
if (budgets.Count == 0) continue;
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var spent = await db.Transactions
.Where(transaction => transaction.UserId == userId &&
transaction.LedgerId == group.Key.LedgerId &&
transaction.Type == TransactionType.Expense &&
transaction.OccurredAt >= start && transaction.OccurredAt < end)
.GroupBy(transaction => transaction.CategoryId)
.Select(items => new { CategoryId = items.Key, Amount = items.Sum(item => item.Amount) })
.ToDictionaryAsync(item => item.CategoryId, item => item.Amount, ct);
var existing = await db.BudgetNotificationReceipts
.Where(receipt => receipt.UserId == userId && receipt.Period == period &&
budgets.Select(budget => budget.Id).Contains(receipt.BudgetId))
.Select(receipt => new { receipt.BudgetId, receipt.Threshold })
.ToListAsync(ct);
var existingKeys = existing.Select(item => (item.BudgetId, item.Threshold)).ToHashSet();
foreach (var budget in budgets)
{
var currentSpent = budget.CategoryId.HasValue
? spent.GetValueOrDefault(budget.CategoryId.Value)
: spent.Values.Sum();
var delta = budget.CategoryId.HasValue
? group.Where(change => change.CategoryId == budget.CategoryId.Value).Sum(change => change.Delta)
: group.Sum(change => change.Delta);
var previousSpent = currentSpent - delta;
var highestCrossed = 0;
foreach (var threshold in Thresholds)
{
if (currentSpent * 100 < budget.Amount * threshold ||
existingKeys.Contains((budget.Id, threshold))) continue;
db.BudgetNotificationReceipts.Add(new BudgetNotificationReceipt
{
UserId = userId,
BudgetId = budget.Id,
Period = period,
Threshold = threshold,
CreatedAt = DateTime.UtcNow,
});
existingKeys.Add((budget.Id, threshold));
if (delta > 0 && previousSpent * 100 < budget.Amount * threshold)
highestCrossed = threshold;
}
if (highestCrossed > 0)
crossed.Add(new CrossedBudget(budget.CategoryId, highestCrossed));
}
}
if (crossed.Count == 0) return;
var notificationsEnabled = await db.UserPushPreferences.AnyAsync(preference =>
preference.UserId == userId && preference.Category == PushCategories.Budget &&
preference.IsEnabled, ct);
if (!notificationsEnabled) return;
var categoryIds = crossed.Where(item => item.CategoryId.HasValue)
.Select(item => item.CategoryId!.Value).Distinct().ToList();
var names = await db.Categories.Where(category => categoryIds.Contains(category.Id))
.ToDictionaryAsync(category => category.Id, category => category.Name, ct);
var details = crossed
.OrderByDescending(item => item.Threshold)
.ThenBy(item => item.CategoryId)
.Select(item =>
$"{(item.CategoryId.HasValue ? names.GetValueOrDefault(item.CategoryId.Value, "") : "")}" +
(item.Threshold >= 100 ? "已用完" : "已使用 80%"))
.Distinct()
.ToList();
var body = string.Join("", details);
if (body.Length > 240) body = body[..237] + "...";
var now = DateTime.UtcNow;
db.PushMessages.Add(new PushMessage
{
PublicId = Guid.NewGuid().ToString(),
Source = "budget",
State = PushMessageStates.Queued,
Category = PushCategories.Budget,
Title = crossed.Any(item => item.Threshold >= 100) ? "预算已达到上限" : "预算接近上限",
Body = body,
Action = PushActions.Budget,
TargetUserId = userId,
Flavor = "",
TtlSeconds = 24 * 3600,
CreatedAt = now,
UpdatedAt = now,
});
}
private sealed record ChangeWithPeriod(long LedgerId, long CategoryId, int Period, decimal Delta);
private sealed record CrossedBudget(long? CategoryId, int Threshold);
}
@@ -0,0 +1,358 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed class PushDispatchService(
IServiceScopeFactory scopeFactory,
IConfiguration configuration,
ILogger<PushDispatchService> logger) : BackgroundService
{
private static readonly TimeSpan[] RetrySchedule =
[
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(30),
TimeSpan.FromHours(2),
TimeSpan.FromHours(6),
];
private DateTime nextCleanupAt = DateTime.MinValue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (!stoppingToken.IsCancellationRequested)
{
if (configuration.GetValue<bool>("Push:Enabled"))
{
try
{
await RecoverAndFinalizeMessages(stoppingToken);
await ExpandMessages(stoppingToken);
await DispatchDeliveries(stoppingToken);
if (nextCleanupAt <= DateTime.UtcNow)
{
await Cleanup(stoppingToken);
nextCleanupAt = DateTime.UtcNow.AddHours(6);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Push dispatch loop failed");
}
}
await timer.WaitForNextTickAsync(stoppingToken);
}
}
private async Task RecoverAndFinalizeMessages(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var now = DateTime.UtcNow;
var staleBefore = now.AddMinutes(-3);
await db.PushMessages
.Where(message => message.State == PushMessageStates.Sending &&
message.StartedAt <= staleBefore &&
!db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id))
.ExecuteUpdateAsync(setters => setters
.SetProperty(message => message.State, PushMessageStates.Queued)
.SetProperty(message => message.StartedAt, (DateTime?)null)
.SetProperty(message => message.UpdatedAt, now), ct);
var ready = await db.PushMessages
.Where(message => message.State == PushMessageStates.Sending &&
db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id) &&
!db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id &&
(delivery.State == PushDeliveryStates.Queued ||
delivery.State == PushDeliveryStates.Sending)))
.Select(message => message.Id)
.Take(100)
.ToListAsync(ct);
foreach (var messageId in ready)
await FinalizeMessage(db, messageId, ct);
}
private async Task ExpandMessages(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var now = DateTime.UtcNow;
var candidates = await db.PushMessages
.Where(message =>
message.State == PushMessageStates.Queued ||
(message.State == PushMessageStates.Scheduled && message.ScheduledAt <= now))
.OrderBy(message => message.ScheduledAt ?? message.CreatedAt)
.Select(message => message.Id)
.Take(20)
.ToListAsync(ct);
foreach (var id in candidates)
{
var claimed = await db.PushMessages
.Where(message => message.Id == id &&
(message.State == PushMessageStates.Queued ||
(message.State == PushMessageStates.Scheduled && message.ScheduledAt <= now)))
.ExecuteUpdateAsync(setters => setters
.SetProperty(message => message.State, PushMessageStates.Sending)
.SetProperty(message => message.StartedAt, now)
.SetProperty(message => message.UpdatedAt, now), ct);
if (claimed != 1) continue;
var message = await db.PushMessages.FirstAsync(item => item.Id == id, ct);
var deviceQuery = db.PushDevices
.Where(device => device.IsActive && device.NotificationsAllowed &&
!device.User.IsBanned && device.User.AccountClosureScheduledAt == null);
if (message.IsTest)
{
deviceQuery = deviceQuery.Where(device => device.Id == message.TestDeviceId);
}
else
{
deviceQuery = deviceQuery.Where(device =>
db.UserPushPreferences.Any(preference =>
preference.UserId == device.UserId &&
preference.Category == message.Category &&
preference.IsEnabled));
if (message.TargetUserId.HasValue)
deviceQuery = deviceQuery.Where(device => device.UserId == message.TargetUserId.Value);
if (!string.IsNullOrWhiteSpace(message.Flavor))
deviceQuery = deviceQuery.Where(device => device.Flavor == message.Flavor);
if (!string.IsNullOrWhiteSpace(message.ProviderFilter))
deviceQuery = deviceQuery.Where(device => device.Provider == message.ProviderFilter);
if (message.MinVersionCode.HasValue)
deviceQuery = deviceQuery.Where(device => device.VersionCode >= message.MinVersionCode.Value);
if (message.MaxVersionCode.HasValue)
deviceQuery = deviceQuery.Where(device => device.VersionCode <= message.MaxVersionCode.Value);
}
var devices = await deviceQuery.Select(device => new
{
device.Id,
device.UserId,
device.Provider,
}).ToListAsync(ct);
var existing = await db.PushDeliveries
.Where(delivery => delivery.PushMessageId == id)
.Select(delivery => delivery.PushDeviceId)
.ToListAsync(ct);
var existingIds = existing.ToHashSet();
foreach (var device in devices.Where(device => !existingIds.Contains(device.Id)))
{
db.PushDeliveries.Add(new PushDelivery
{
PushMessageId = id,
PushDeviceId = device.Id,
UserId = device.UserId,
Provider = device.Provider,
State = PushDeliveryStates.Queued,
NextAttemptAt = now,
CreatedAt = now,
UpdatedAt = now,
});
}
if (devices.Count == 0)
{
message.State = PushMessageStates.Completed;
message.CompletedAt = now;
message.UpdatedAt = now;
}
await db.SaveChangesAsync(ct);
}
}
private async Task DispatchDeliveries(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var registry = scope.ServiceProvider.GetRequiredService<PushProviderRegistry>();
var tokenProtector = scope.ServiceProvider.GetRequiredService<PushTokenProtector>();
var now = DateTime.UtcNow;
var candidates = await db.PushDeliveries
.Where(delivery =>
(delivery.State == PushDeliveryStates.Queued && delivery.NextAttemptAt <= now) ||
(delivery.State == PushDeliveryStates.Sending && delivery.LeaseExpiresAt <= now))
.OrderBy(delivery => delivery.NextAttemptAt)
.Select(delivery => delivery.Id)
.Take(50)
.ToListAsync(ct);
var affectedMessages = new HashSet<long>();
foreach (var id in candidates)
{
var claimNow = DateTime.UtcNow;
var leaseId = Guid.NewGuid().ToString();
var leaseUntil = claimNow.AddMinutes(2);
var claimed = await db.PushDeliveries
.Where(delivery => delivery.Id == id &&
((delivery.State == PushDeliveryStates.Queued && delivery.NextAttemptAt <= claimNow) ||
(delivery.State == PushDeliveryStates.Sending && delivery.LeaseExpiresAt <= claimNow)))
.ExecuteUpdateAsync(setters => setters
.SetProperty(delivery => delivery.State, PushDeliveryStates.Sending)
.SetProperty(delivery => delivery.LeaseId, leaseId)
.SetProperty(delivery => delivery.LeaseExpiresAt, leaseUntil)
.SetProperty(delivery => delivery.AttemptCount, delivery => delivery.AttemptCount + 1)
.SetProperty(delivery => delivery.UpdatedAt, claimNow), ct);
if (claimed != 1) continue;
var delivery = await db.PushDeliveries
.Include(item => item.PushMessage)
.Include(item => item.PushDevice).ThenInclude(device => device.User)
.FirstAsync(item => item.Id == id, ct);
affectedMessages.Add(delivery.PushMessageId);
await SendOne(db, registry, tokenProtector, delivery, ct);
}
foreach (var messageId in affectedMessages)
await FinalizeMessage(db, messageId, ct);
}
private static async Task SendOne(
AppDbContext db,
PushProviderRegistry registry,
PushTokenProtector tokenProtector,
PushDelivery delivery,
CancellationToken ct)
{
var now = DateTime.UtcNow;
var message = delivery.PushMessage;
var device = delivery.PushDevice;
var expiresAt = (message.ScheduledAt ?? message.CreatedAt).AddSeconds(message.TtlSeconds);
if (expiresAt <= now)
{
Skip(delivery, "message_expired", now);
await db.SaveChangesAsync(ct);
return;
}
if (!device.IsActive || !device.NotificationsAllowed ||
device.User.IsBanned || device.User.AccountClosureScheduledAt.HasValue)
{
Skip(delivery, "device_or_account_inactive", now);
await db.SaveChangesAsync(ct);
return;
}
if (!message.IsTest && !await db.UserPushPreferences.AnyAsync(preference =>
preference.UserId == device.UserId && preference.Category == message.Category &&
preference.IsEnabled, ct))
{
Skip(delivery, "category_disabled", now);
await db.SaveChangesAsync(ct);
return;
}
var provider = registry.Find(device.Provider);
if (provider is null)
{
Fail(delivery, "provider_unknown", "Unknown push provider", now);
await db.SaveChangesAsync(ct);
return;
}
var envelope = new PushEnvelope(
message.PublicId,
message.Title,
message.Body,
message.Category,
message.Action,
message.EntityId,
Math.Max(60, (int)(expiresAt - now).TotalSeconds));
var result = await provider.SendAsync(
device.Flavor,
device.PackageName,
tokenProtector.Unprotect(device.TokenCiphertext),
envelope,
ct);
if (result.Accepted)
{
delivery.State = PushDeliveryStates.Accepted;
delivery.AcceptedAt = now;
delivery.ProviderMessageId = result.ProviderMessageId;
delivery.ErrorCode = null;
delivery.ErrorMessage = null;
ClearLease(delivery, now);
}
else if (result.Retryable && delivery.AttemptCount <= RetrySchedule.Length && expiresAt > now)
{
var retry = result.RetryAfter ?? RetrySchedule[Math.Clamp(delivery.AttemptCount - 1, 0, RetrySchedule.Length - 1)];
delivery.State = PushDeliveryStates.Queued;
delivery.NextAttemptAt = now.Add(retry) < expiresAt ? now.Add(retry) : expiresAt;
delivery.ErrorCode = result.ErrorCode;
delivery.ErrorMessage = result.ErrorMessage;
ClearLease(delivery, now);
}
else
{
Fail(delivery, result.ErrorCode ?? "provider_rejected", result.ErrorMessage, now);
}
if (result.InvalidToken)
{
device.IsActive = false;
device.DisabledReason = "provider_invalid_token";
device.UpdatedAt = now;
}
await db.SaveChangesAsync(ct);
}
private static async Task FinalizeMessage(AppDbContext db, long messageId, CancellationToken ct)
{
var pending = await db.PushDeliveries.AnyAsync(delivery =>
delivery.PushMessageId == messageId &&
(delivery.State == PushDeliveryStates.Queued || delivery.State == PushDeliveryStates.Sending), ct);
if (pending) return;
var failed = await db.PushDeliveries.AnyAsync(delivery =>
delivery.PushMessageId == messageId && delivery.State == PushDeliveryStates.Failed, ct);
var now = DateTime.UtcNow;
await db.PushMessages.Where(message => message.Id == messageId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(message => message.State,
failed ? PushMessageStates.PartiallyFailed : PushMessageStates.Completed)
.SetProperty(message => message.CompletedAt, now)
.SetProperty(message => message.UpdatedAt, now), ct);
}
private async Task Cleanup(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var staleBefore = DateTime.UtcNow.AddDays(-90);
await db.PushDevices
.Where(device => device.IsActive && device.LastSeenAt < staleBefore)
.ExecuteUpdateAsync(setters => setters
.SetProperty(device => device.IsActive, false)
.SetProperty(device => device.DisabledReason, "stale_device")
.SetProperty(device => device.UpdatedAt, DateTime.UtcNow), ct);
await db.PushDeliveries
.Where(delivery => delivery.UpdatedAt < staleBefore &&
delivery.State != PushDeliveryStates.Queued &&
delivery.State != PushDeliveryStates.Sending)
.ExecuteDeleteAsync(ct);
}
private static void Skip(PushDelivery delivery, string code, DateTime now)
{
delivery.State = PushDeliveryStates.Skipped;
delivery.ErrorCode = code;
delivery.ErrorMessage = null;
ClearLease(delivery, now);
}
private static void Fail(PushDelivery delivery, string code, string? message, DateTime now)
{
delivery.State = PushDeliveryStates.Failed;
delivery.ErrorCode = code;
delivery.ErrorMessage = message is { Length: > 400 } ? message[..400] : message;
ClearLease(delivery, now);
}
private static void ClearLease(PushDelivery delivery, DateTime now)
{
delivery.LeaseId = null;
delivery.LeaseExpiresAt = null;
delivery.UpdatedAt = now;
}
}
@@ -0,0 +1,571 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Collections.Concurrent;
using MiaoJiZhang.Domain.Entities;
namespace MiaoJiZhang.Api.Services;
public sealed record PushEnvelope(
string MessageId,
string Title,
string Body,
string Category,
string Action,
string? EntityId,
int TtlSeconds);
public sealed record PushSendResult(
bool Accepted,
bool Retryable,
bool InvalidToken,
string? ProviderMessageId = null,
string? ErrorCode = null,
string? ErrorMessage = null,
TimeSpan? RetryAfter = null);
public interface IPushProvider
{
string Provider { get; }
bool IsEnabled(string flavor);
IReadOnlyList<string> ConfigurationErrors(string flavor);
Task<PushSendResult> SendAsync(
string flavor,
string packageName,
string token,
PushEnvelope message,
CancellationToken ct);
}
public sealed class PushProviderRegistry(IEnumerable<IPushProvider> providers)
{
private readonly IReadOnlyDictionary<string, IPushProvider> items = providers
.ToDictionary(provider => provider.Provider, StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection<IPushProvider> All => items.Values.ToArray();
public IPushProvider? Find(string provider) => items.GetValueOrDefault(provider);
}
public sealed class OfficialPushProvider(
string provider,
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
ILogger<OfficialPushProvider> logger) : IPushProvider
{
private readonly SemaphoreSlim tokenLock = new(1, 1);
private readonly ConcurrentDictionary<string, CachedAccessToken> accessTokens =
new(StringComparer.OrdinalIgnoreCase);
public string Provider { get; } = provider;
public bool IsEnabled(string flavor) =>
configuration.GetValue<bool>($"Push:Providers:{Provider}:{flavor}:Enabled");
public IReadOnlyList<string> ConfigurationErrors(string flavor)
{
if (!IsEnabled(flavor)) return ["disabled"];
var required = Provider switch
{
PushProviders.Huawei or PushProviders.Honor => new[] { "AppId", "AppSecret" },
PushProviders.Xiaomi => new[] { "AppSecret" },
PushProviders.Oppo => new[] { "AppKey", "MasterSecret" },
PushProviders.Vivo => new[] { "AppId", "AppKey", "AppSecret" },
PushProviders.Meizu => new[] { "AppId", "AppSecret" },
_ => [],
};
return required
.Where(key => string.IsNullOrWhiteSpace(Value(flavor, key)))
.Select(key => $"missing_{key.ToLowerInvariant()}")
.ToList();
}
public async Task<PushSendResult> SendAsync(
string flavor,
string packageName,
string token,
PushEnvelope message,
CancellationToken ct)
{
var errors = ConfigurationErrors(flavor);
if (errors.Count > 0)
return new(false, false, false, ErrorCode: "provider_not_configured",
ErrorMessage: string.Join(',', errors));
try
{
return Provider switch
{
PushProviders.Huawei => await SendHuaweiLike(flavor, packageName, token, message, false, ct),
PushProviders.Honor => await SendHuaweiLike(flavor, packageName, token, message, true, ct),
PushProviders.Xiaomi => await SendXiaomi(flavor, packageName, token, message, ct),
PushProviders.Oppo => await SendOppo(flavor, packageName, token, message, ct),
PushProviders.Vivo => await SendVivo(flavor, packageName, token, message, ct),
PushProviders.Meizu => await SendMeizu(flavor, packageName, token, message, ct),
_ => new(false, false, false, ErrorCode: "provider_unknown"),
};
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return new(false, true, false, ErrorCode: "provider_timeout", ErrorMessage: "Provider request timed out");
}
catch (HttpRequestException exception)
{
logger.LogWarning(exception, "Push provider {Provider} request failed", Provider);
return new(false, true, false, ErrorCode: "provider_network_error", ErrorMessage: exception.Message);
}
catch (Exception exception)
{
logger.LogError(exception, "Push provider {Provider} failed unexpectedly", Provider);
return new(false, false, false, ErrorCode: "provider_internal_error", ErrorMessage: exception.Message);
}
}
private async Task<PushSendResult> SendHuaweiLike(
string flavor,
string packageName,
string token,
PushEnvelope message,
bool honor,
CancellationToken ct)
{
var accessToken = await GetOAuthToken(flavor, honor, ct);
if (accessToken.Result is not null) return accessToken.Result;
var appId = Value(flavor, "AppId")!;
var defaultUrl = honor
? $"https://push-api.cloud.hihonor.com/api/v1/{appId}/sendMessage"
: $"https://push-api.cloud.huawei.com/v1/{appId}/messages:send";
var url = Value(flavor, "SendUrl") ?? defaultUrl;
var intent = IntentUri(packageName, message);
object payload = honor
? new
{
message = new
{
notification = new { title = message.Title, body = message.Body },
android = new
{
ttl = $"{message.TtlSeconds}s",
data = PayloadJson(message),
notification = new
{
channel_id = Channel(flavor, message.Category),
click_action = new { type = 1, intent },
},
},
token = new[] { token },
},
}
: new
{
validate_only = false,
message = new
{
notification = new { title = message.Title, body = message.Body },
android = new
{
ttl = $"{message.TtlSeconds}s",
data = PayloadJson(message),
notification = new
{
channel_id = Channel(flavor, message.Category),
notify_id = StableNotificationId(message.MessageId),
click_action = new { type = 1, intent },
},
},
token = new[] { token },
},
};
using var request = JsonRequest(HttpMethod.Post, url, payload);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token);
return await Send(request, ct);
}
private async Task<(string? Token, PushSendResult? Result)> GetOAuthToken(
string flavor,
bool honor,
CancellationToken ct)
{
if (FreshAccessToken(flavor) is { } cached) return (cached, null);
await tokenLock.WaitAsync(ct);
try
{
if (FreshAccessToken(flavor) is { } lockedCached) return (lockedCached, null);
var defaultUrl = honor
? "https://iam.developer.hihonor.com/auth/token"
: "https://oauth-login.cloud.huawei.com/oauth2/v3/token";
var url = Value(flavor, "AuthUrl") ?? defaultUrl;
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = Value(flavor, "AppId")!,
["client_secret"] = Value(flavor, "AppSecret")!,
}),
};
using var response = await Client().SendAsync(request, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
return (null, FromFailure(response, body));
using var json = JsonDocument.Parse(body);
if (!TryString(json.RootElement, out var value, "access_token", "accessToken", "token"))
return (null, new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(body)));
var expires = TryInt(json.RootElement, "expires_in", "expiresIn") ?? 3600;
accessTokens[flavor] = new CachedAccessToken(
value!,
DateTime.UtcNow.AddSeconds(Math.Max(expires, 300)));
return (value, null);
}
finally
{
tokenLock.Release();
}
}
private async Task<PushSendResult> SendXiaomi(
string flavor,
string packageName,
string token,
PushEnvelope message,
CancellationToken ct)
{
var url = Value(flavor, "SendUrl") ?? "https://api.xmpush.xiaomi.com/v3/message/regid";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["registration_id"] = token,
["restricted_package_name"] = packageName,
["title"] = message.Title,
["description"] = message.Body,
["notify_id"] = StableNotificationId(message.MessageId).ToString(),
["time_to_live"] = (message.TtlSeconds * 1000L).ToString(),
["extra.notify_effect"] = "2",
["extra.intent_uri"] = IntentUri(packageName, message),
["extra.jz_payload"] = PayloadJson(message),
["extra.channel_id"] = Channel(flavor, message.Category),
}),
};
request.Headers.TryAddWithoutValidation("Authorization", $"key={Value(flavor, "AppSecret")}");
return await Send(request, ct);
}
private async Task<PushSendResult> SendOppo(
string flavor,
string packageName,
string token,
PushEnvelope message,
CancellationToken ct)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
var appKey = Value(flavor, "AppKey")!;
var sign = Sha256Hex(appKey + timestamp + Value(flavor, "MasterSecret"));
var authUrl = Value(flavor, "AuthUrl") ?? "https://api.push.oppomobile.com/server/v1/auth";
using var authRequest = new HttpRequestMessage(HttpMethod.Post, authUrl)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["app_key"] = appKey,
["timestamp"] = timestamp,
["sign"] = sign,
}),
};
using var authResponse = await Client().SendAsync(authRequest, ct);
var authBody = await authResponse.Content.ReadAsStringAsync(ct);
if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody);
using var authJson = JsonDocument.Parse(authBody);
if (!TryNestedString(authJson.RootElement, out var authToken, "data", "auth_token") &&
!TryString(authJson.RootElement, out authToken, "auth_token", "authToken"))
return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody));
var notification = JsonSerializer.Serialize(new
{
app_message_id = message.MessageId,
title = message.Title,
content = message.Body,
click_action_type = 1,
click_action_activity = $"{packageName}/com.nx.miaoji.MainActivity",
action_parameters = PayloadJson(message),
off_line = true,
off_line_ttl = message.TtlSeconds,
channel_id = Channel(flavor, message.Category),
});
var sendUrl = Value(flavor, "SendUrl") ??
"https://api.push.oppomobile.com/server/v1/message/notification/unicast";
using var request = new HttpRequestMessage(HttpMethod.Post, sendUrl)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["auth_token"] = authToken!,
["registration_id"] = token,
["message"] = notification,
}),
};
return await Send(request, ct);
}
private async Task<PushSendResult> SendVivo(
string flavor,
string packageName,
string token,
PushEnvelope message,
CancellationToken ct)
{
var appId = Value(flavor, "AppId")!;
var appKey = Value(flavor, "AppKey")!;
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
var sign = Md5Hex(appId + appKey + timestamp + Value(flavor, "AppSecret"));
var authUrl = Value(flavor, "AuthUrl") ?? "https://api-push.vivo.com.cn/message/auth";
using var authRequest = JsonRequest(HttpMethod.Post, authUrl, new
{
appId = int.TryParse(appId, out var id) ? id : 0,
appKey,
timestamp = long.Parse(timestamp),
sign,
});
using var authResponse = await Client().SendAsync(authRequest, ct);
var authBody = await authResponse.Content.ReadAsStringAsync(ct);
if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody);
using var authJson = JsonDocument.Parse(authBody);
if (!TryString(authJson.RootElement, out var authToken, "authToken", "auth_token"))
return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody));
var sendUrl = Value(flavor, "SendUrl") ?? "https://api-push.vivo.com.cn/message/send";
using var request = JsonRequest(HttpMethod.Post, sendUrl, new
{
regId = token,
notifyType = 4,
title = message.Title,
content = message.Body,
timeToLive = message.TtlSeconds,
skipType = 3,
skipContent = IntentUri(packageName, message),
requestId = message.MessageId,
classification = message.Category == PushCategories.Operations ? 1 : 0,
clientCustomMap = new Dictionary<string, string> { ["jz_payload"] = PayloadJson(message) },
});
request.Headers.TryAddWithoutValidation("authToken", authToken);
return await Send(request, ct);
}
private async Task<PushSendResult> SendMeizu(
string flavor,
string packageName,
string token,
PushEnvelope message,
CancellationToken ct)
{
var appId = Value(flavor, "AppId")!;
var messageJson = JsonSerializer.Serialize(new
{
noticeBarInfo = new
{
title = message.Title,
content = message.Body,
noticeBarType = 0,
},
clickTypeInfo = new
{
clickType = 3,
parameters = new Dictionary<string, string> { ["jz_payload"] = PayloadJson(message) },
uri = IntentUri(packageName, message),
},
pushTimeInfo = new { offLine = true, validTime = message.TtlSeconds / 3600 },
advanceInfo = new { notifyId = StableNotificationId(message.MessageId) },
});
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["appId"] = appId,
["pushIds"] = JsonSerializer.Serialize(new[] { token }),
["messageJson"] = messageJson,
};
var signSource = string.Concat(values.Select(item => item.Key + item.Value)) + Value(flavor, "AppSecret");
values["sign"] = Md5Hex(signSource);
var url = Value(flavor, "SendUrl") ??
"https://server-api-push.meizu.com/garcia/api/server/push/varnished/pushByPushId";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new FormUrlEncodedContent(values),
};
return await Send(request, ct);
}
private async Task<PushSendResult> Send(HttpRequestMessage request, CancellationToken ct)
{
using var response = await Client().SendAsync(request, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode) return FromFailure(response, body);
string? providerId = null;
try
{
using var json = JsonDocument.Parse(body);
var businessFailure = FromBusinessFailure(json.RootElement, body);
if (businessFailure is not null) return businessFailure;
TryString(json.RootElement, out providerId,
"requestId", "request_id", "taskId", "msgId", "messageId", "code");
}
catch (JsonException)
{
// Some providers return an empty or non-JSON success body.
}
return new(true, false, false, providerId);
}
private PushSendResult? FromBusinessFailure(JsonElement root, string body)
{
string? code = null;
var failed = Provider switch
{
PushProviders.Huawei or PushProviders.Honor =>
HasUnexpectedValue(root, ["0", "200", "80000000"], out code, "code"),
PushProviders.Xiaomi =>
HasUnexpectedValue(root, ["ok", "success"], out code, "result") ||
HasUnexpectedValue(root, ["0"], out code, "code"),
PushProviders.Oppo =>
HasUnexpectedValue(root, ["0"], out code, "code"),
PushProviders.Vivo =>
HasUnexpectedValue(root, ["0"], out code, "result", "code"),
PushProviders.Meizu =>
HasUnexpectedValue(root, ["200"], out code, "code"),
_ => false,
};
if (!failed && TryString(root, out var error, "error", "error_description") &&
!string.IsNullOrWhiteSpace(error))
{
code = error;
failed = true;
}
if (!failed) return null;
var normalized = body.ToLowerInvariant();
var retryable = normalized.Contains("rate limit") || normalized.Contains("too many") ||
normalized.Contains("frequency") || normalized.Contains("system busy") ||
normalized.Contains("try again");
return new PushSendResult(
false,
retryable,
LooksLikeInvalidToken(normalized),
ErrorCode: $"provider_{NormalizeCode(code)}",
ErrorMessage: Trim(body));
}
private static PushSendResult FromFailure(HttpResponseMessage response, string body)
{
var normalized = body.ToLowerInvariant();
var retryable = response.StatusCode == HttpStatusCode.TooManyRequests ||
(int)response.StatusCode >= 500;
TimeSpan? retryAfter = response.Headers.RetryAfter?.Delta;
return new(false, retryable, LooksLikeInvalidToken(normalized),
ErrorCode: $"http_{(int)response.StatusCode}",
ErrorMessage: Trim(body), RetryAfter: retryAfter);
}
private string? FreshAccessToken(string flavor) =>
accessTokens.TryGetValue(flavor, out var cached) &&
cached.ExpiresAt > DateTime.UtcNow.AddMinutes(2)
? cached.Token
: null;
private HttpClient Client() => httpClientFactory.CreateClient("PushProviders");
private string? Value(string flavor, string key) =>
configuration[$"Push:Providers:{Provider}:{flavor}:{key}"];
private string Channel(string flavor, string category) =>
Value(flavor, $"Channels:{category}") ?? $"jizhi_{category}";
private static HttpRequestMessage JsonRequest(HttpMethod method, string url, object body) => new(method, url)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
};
private static string PayloadJson(PushEnvelope message) => JsonSerializer.Serialize(new
{
v = 1,
messageId = message.MessageId,
category = message.Category,
action = message.Action,
entityId = message.EntityId,
});
private static string IntentUri(string packageName, PushEnvelope message)
{
var entity = message.EntityId is null ? "" : $"&entityId={Uri.EscapeDataString(message.EntityId)}";
return $"miaoji://push/open?messageId={Uri.EscapeDataString(message.MessageId)}" +
$"&category={Uri.EscapeDataString(message.Category)}&action={Uri.EscapeDataString(message.Action)}{entity}";
}
private static int StableNotificationId(string messageId)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(messageId));
return BitConverter.ToInt32(bytes, 0) & int.MaxValue;
}
private static string Sha256Hex(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string Md5Hex(string value) =>
Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string Trim(string value) => value.Length <= 400 ? value : value[..400];
private static bool HasUnexpectedValue(
JsonElement root,
string[] accepted,
out string? value,
params string[] names)
{
foreach (var name in names)
{
if (!TryString(root, out value, name)) continue;
return !accepted.Contains(value!);
}
value = null;
return false;
}
private static bool LooksLikeInvalidToken(string normalized) =>
normalized.Contains("invalid token") || normalized.Contains("invalid reg") ||
normalized.Contains("registration_id_invalid") || normalized.Contains("target invalid") ||
normalized.Contains("pushid") && normalized.Contains("invalid");
private static string NormalizeCode(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "rejected";
var normalized = new string(value.Where(character => char.IsLetterOrDigit(character) || character == '_')
.Take(48).ToArray());
return string.IsNullOrEmpty(normalized) ? "rejected" : normalized.ToLowerInvariant();
}
private static bool TryString(JsonElement root, out string? value, params string[] names)
{
foreach (var name in names)
{
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var element))
{
value = element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString();
if (!string.IsNullOrWhiteSpace(value)) return true;
}
}
value = null;
return false;
}
private static bool TryNestedString(JsonElement root, out string? value, string parent, string child)
{
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(parent, out var nested))
return TryString(nested, out value, child);
value = null;
return false;
}
private static int? TryInt(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value)) continue;
if (value.TryGetInt32(out var parsed)) return parsed;
if (int.TryParse(value.ToString(), out parsed)) return parsed;
}
return null;
}
private sealed record CachedAccessToken(string Token, DateTime ExpiresAt);
}
@@ -0,0 +1,67 @@
using System.Security.Cryptography;
using System.Text;
namespace MiaoJiZhang.Api.Services;
public sealed class PushTokenProtector(IConfiguration configuration)
{
private readonly byte[]? key = ReadKey(configuration["Push:TokenEncryptionKey"]);
public bool IsConfigured => key is { Length: 32 };
public string Protect(string value)
{
if (key is null)
throw new InvalidOperationException("Push__TokenEncryptionKey must be a base64-encoded 32-byte key");
var plaintext = Encoding.UTF8.GetBytes(value);
var nonce = RandomNumberGenerator.GetBytes(12);
var tag = new byte[16];
var ciphertext = new byte[plaintext.Length];
using var aes = new AesGcm(key, tag.Length);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
var envelope = new byte[nonce.Length + tag.Length + ciphertext.Length];
Buffer.BlockCopy(nonce, 0, envelope, 0, nonce.Length);
Buffer.BlockCopy(tag, 0, envelope, nonce.Length, tag.Length);
Buffer.BlockCopy(ciphertext, 0, envelope, nonce.Length + tag.Length, ciphertext.Length);
return Convert.ToBase64String(envelope);
}
public string Unprotect(string value)
{
if (key is null)
throw new InvalidOperationException("Push__TokenEncryptionKey must be a base64-encoded 32-byte key");
var envelope = Convert.FromBase64String(value);
if (envelope.Length < 29) throw new CryptographicException("Invalid push token envelope");
var nonce = envelope.AsSpan(0, 12);
var tag = envelope.AsSpan(12, 16);
var ciphertext = envelope.AsSpan(28);
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, tag.Length);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return Encoding.UTF8.GetString(plaintext);
}
public static string Hash(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
public static string CreateUnbindToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[]? ReadKey(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
try
{
var parsed = Convert.FromBase64String(value);
return parsed.Length == 32 ? parsed : null;
}
catch (FormatException)
{
return null;
}
}
}
+13 -1
View File
@@ -18,5 +18,17 @@
"AllowedHosts": "*",
"Admin": {
"Key": ""
}
},
"Push": {
"Enabled": false,
"TokenEncryptionKey": "",
"Providers": {
"huawei": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
"honor": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
"xiaomi": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
"oppo": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
"vivo": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
"meizu": { "production": { "Enabled": false }, "internal": { "Enabled": false } }
}
}
}
@@ -1 +0,0 @@
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};
@@ -1 +0,0 @@
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,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,t as _}from"./EditOutlined-h6ScL3Qz.js";import{t as v}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as y}from"./api-wmB-hCXT.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(g)),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(_))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(v))]),_: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};
@@ -1 +0,0 @@
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};
@@ -1 +0,0 @@
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};
@@ -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,gn as f,or as p,vn as m,xn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{t as _}from"./api-wmB-hCXT.js";import{t as v}from"./time-pIfF89ap.js";var y={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},x={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},S={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},C={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},w={key:0,style:{color:`#00B386`}},T={key:1,style:{color:`#ccc`}},E={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},D={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},O={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},k=t({__name:`Configs`,setup(t){let k=a([]),A=a(!0),j={"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`}},M=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],N=f(()=>{let e={};for(let t of k.value){let n=M.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(P);async function P(){A.value=!0;try{k.value=await _.configs()}finally{A.value=!1}}let F=a(!1),I=a(null),L=a(``),R=f(()=>I.value?j[I.value.key]:null);function z(e){I.value=e,L.value=e.value,F.value=!0}async function B(){I.value&&(await _.updateConfig(I.value.id,L.value),c.success(`已更新 ${I.value.key}`),F.value=!1,P())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),f=e(`a-card`),_=e(`a-col`),k=e(`a-row`),A=e(`a-tab-pane`),V=e(`a-tabs`),H=e(`a-switch`),U=e(`a-input-number`),W=e(`a-select`),G=e(`a-input`),K=e(`a-modal`);return r(),u(`div`,null,[s(`div`,y,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:P},{default:n(()=>[...i[5]||=[h(`刷新`,-1)]]),_:1})]),o(V,null,{default:n(()=>[(r(),u(d,null,g(M,e=>o(A,{key:e.key,tab:e.label},{default:n(()=>[o(k,{gutter:[16,12]},{default:n(()=>[(r(!0),u(d,null,g(N.value[e.key],e=>(r(),m(_,{key:e.id,span:8},{default:n(()=>[o(f,{size:`small`,hoverable:``,onClick:t=>z(e)},{default:n(()=>[s(`div`,b,[s(`div`,null,[s(`div`,x,p(j[e.key]?.label||e.key),1),s(`div`,S,p(j[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[h(`v`+p(e.version),1)]),_:2},1024)]),j[e.key]?.type===`switch`?(r(),u(`div`,C,[e.value===`true`?(r(),u(`span`,w,`✅ 已开启`)):(r(),u(`span`,T,`❌ 已关闭`))])):(r(),u(`div`,E,p(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,D,p(l(v)(e.updatedAt)),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(K,{open:F.value,"onUpdate:open":i[4]||=e=>F.value=e,title:`编辑配置: ${I.value?.key}`,onOk:B,width:440},{default:n(()=>[s(`div`,O,p(R.value?.desc),1),R.value?.type===`switch`?(r(),m(H,{key:0,checked:L.value===`true`,onChange:i[0]||=e=>L.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):R.value?.type===`number`?(r(),m(U,{key:1,value:L.value,"onUpdate:value":i[1]||=e=>L.value=e,style:{width:`100%`}},null,8,[`value`])):R.value?.type===`select`&&R.value.options?(r(),m(W,{key:2,value:L.value,"onUpdate:value":i[2]||=e=>L.value=e,style:{width:`100%`},options:R.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),m(G,{key:3,value:L.value,"onUpdate:value":i[3]||=e=>L.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{k as default};
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:`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 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=`DeleteOutlined`,a.inheritAttrs=!1;export{a as t};
@@ -1 +0,0 @@
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{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:`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 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=`EditOutlined`,l.inheritAttrs=!1;export{a as n,l 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,t as _}from"./EditOutlined-h6ScL3Qz.js";import{t as v}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as y}from"./api-wmB-hCXT.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(g)),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(_))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(v))]),_: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};
@@ -1 +0,0 @@
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};
@@ -1 +0,0 @@
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};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.page-header[data-v-3ceae462],.toolbar[data-v-3ceae462],.modal-actions[data-v-3ceae462],.estimate-row[data-v-3ceae462]{justify-content:space-between;align-items:center;gap:12px;display:flex}.page-header[data-v-3ceae462]{margin-bottom:16px}.page-header h2[data-v-3ceae462]{margin:0}.toolbar[data-v-3ceae462]{margin-bottom:12px}.subtle[data-v-3ceae462]{color:#8c8c8c;font-size:12px}.campaign-title[data-v-3ceae462]{margin-bottom:3px;font-weight:600}.campaign-body[data-v-3ceae462]{color:#595959;white-space:pre-wrap;margin-bottom:6px;font-size:12px}.action-label[data-v-3ceae462]{color:#8c8c8c;font-size:12px}.mono[data-v-3ceae462]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.estimate-row[data-v-3ceae462]{justify-content:flex-start;min-height:32px;margin-bottom:16px}.modal-actions[data-v-3ceae462]{justify-content:flex-end;padding-top:4px}@media (width<=760px){.page-header[data-v-3ceae462]{flex-direction:column;align-items:flex-start}}
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
@@ -1 +0,0 @@
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};
@@ -1 +0,0 @@
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};
@@ -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 _,t as v}from"./EditOutlined-h6ScL3Qz.js";import{t as y}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as b}from"./api-wmB-hCXT.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(_)),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(v))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(y))]),_: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
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
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};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
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
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 @@
var e=new Intl.DateTimeFormat(`zh-CN`,{timeZone:`Asia/Shanghai`,year:`numeric`,month:`2-digit`,day:`2-digit`});function t(t){if(!t)return``;let n=/(?:Z|[+-]\d{2}:?\d{2})$/i.test(t)?t:t+`Z`,r=new Date(n);return Number.isNaN(r.getTime())?``:e.format(r).replaceAll(`/`,`-`)}export{t};
+4 -2
View File
@@ -5,9 +5,11 @@
<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>
<script type="module" crossorigin src="/assets/index-BK5aVReu.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dayjs.min-CeCVojfG.js">
<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/EditOutlined-h6ScL3Qz.js">
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-yVoeJ3Fd.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">
+155
View File
@@ -0,0 +1,155 @@
namespace MiaoJiZhang.Domain.Entities;
public static class PushProviders
{
public const string Huawei = "huawei";
public const string Honor = "honor";
public const string Xiaomi = "xiaomi";
public const string Oppo = "oppo";
public const string Vivo = "vivo";
public const string Meizu = "meizu";
public static readonly IReadOnlySet<string> All = new HashSet<string>(
[Huawei, Honor, Xiaomi, Oppo, Vivo, Meizu],
StringComparer.OrdinalIgnoreCase);
}
public static class PushCategories
{
public const string System = "system";
public const string Budget = "budget";
public const string Operations = "operations";
public static readonly IReadOnlySet<string> All = new HashSet<string>(
[System, Budget, Operations],
StringComparer.OrdinalIgnoreCase);
}
public static class PushActions
{
public const string None = "none";
public const string Home = "home";
public const string Budget = "budget";
public const string Update = "update";
public static readonly IReadOnlySet<string> All = new HashSet<string>(
[None, Home, Budget, Update],
StringComparer.OrdinalIgnoreCase);
}
public static class PushMessageStates
{
public const string Draft = "draft";
public const string Scheduled = "scheduled";
public const string Queued = "queued";
public const string Sending = "sending";
public const string Completed = "completed";
public const string PartiallyFailed = "partially_failed";
public const string Cancelled = "cancelled";
}
public static class PushDeliveryStates
{
public const string Queued = "queued";
public const string Sending = "sending";
public const string Accepted = "accepted";
public const string Failed = "failed";
public const string Skipped = "skipped";
}
public class PushDevice
{
public long Id { get; set; }
public long UserId { get; set; }
public User User { get; set; } = null!;
public string InstallationId { get; set; } = null!;
public string Provider { get; set; } = null!;
public string TokenCiphertext { get; set; } = null!;
public string TokenHash { get; set; } = null!;
public string UnbindTokenHash { get; set; } = null!;
public string PackageName { get; set; } = null!;
public string Flavor { get; set; } = null!;
public string AppVersion { get; set; } = null!;
public int VersionCode { get; set; }
public bool NotificationsAllowed { get; set; }
public bool IsActive { get; set; } = true;
public string? DisabledReason { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime LastSeenAt { get; set; }
public List<PushDelivery> Deliveries { get; set; } = [];
}
public class UserPushPreference
{
public long Id { get; set; }
public long UserId { get; set; }
public User User { get; set; } = null!;
public string Category { get; set; } = null!;
public bool IsEnabled { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class PushMessage
{
public long Id { get; set; }
public string PublicId { get; set; } = null!;
public string Source { get; set; } = null!;
public string State { get; set; } = PushMessageStates.Draft;
public string Category { get; set; } = null!;
public string Title { get; set; } = null!;
public string Body { get; set; } = null!;
public string Action { get; set; } = PushActions.None;
public string? EntityId { get; set; }
public long? TargetUserId { get; set; }
public string Flavor { get; set; } = "production";
public string? ProviderFilter { get; set; }
public int? MinVersionCode { get; set; }
public int? MaxVersionCode { get; set; }
public int TtlSeconds { get; set; }
public bool IsTest { get; set; }
public long? TestDeviceId { get; set; }
public DateTime? ScheduledAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public DateTime? CancelledAt { get; set; }
public List<PushDelivery> Deliveries { get; set; } = [];
}
public class PushDelivery
{
public long Id { get; set; }
public long PushMessageId { get; set; }
public PushMessage PushMessage { get; set; } = null!;
public long PushDeviceId { get; set; }
public PushDevice PushDevice { get; set; } = null!;
public long UserId { get; set; }
public string Provider { get; set; } = null!;
public string State { get; set; } = PushDeliveryStates.Queued;
public int AttemptCount { get; set; }
public DateTime NextAttemptAt { get; set; }
public string? LeaseId { get; set; }
public DateTime? LeaseExpiresAt { get; set; }
public string? ProviderMessageId { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? AcceptedAt { get; set; }
}
public class BudgetNotificationReceipt
{
public long Id { get; set; }
public long UserId { get; set; }
public User User { get; set; } = null!;
public long BudgetId { get; set; }
public Budget Budget { get; set; } = null!;
public int Period { get; set; }
public int Threshold { get; set; }
public DateTime CreatedAt { get; set; }
}
+5 -3
View File
@@ -38,9 +38,11 @@ public class User
public DateTime CreatedAt { get; set; }
public DateTime? LastLoginAt { get; set; }
public List<Ledger> Ledgers { get; set; } = [];
public List<UserFeaturePermission> FeaturePermissions { get; set; } = [];
}
public List<Ledger> Ledgers { get; set; } = [];
public List<UserFeaturePermission> FeaturePermissions { get; set; } = [];
public List<PushDevice> PushDevices { get; set; } = [];
public List<UserPushPreference> PushPreferences { get; set; } = [];
}
/// <summary>AI 伙伴设置(形象/昵称/性格/滑杆),1:1 User</summary>
public class UserFeaturePermission
@@ -17,7 +17,12 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<AppConfig> AppConfigs => Set<AppConfig>();
public DbSet<AiPersona> AiPersonas => Set<AiPersona>();
public DbSet<AiAvatar> AiAvatars => Set<AiAvatar>();
public DbSet<Sticker> Stickers => Set<Sticker>();
public DbSet<Sticker> Stickers => Set<Sticker>();
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
public DbSet<UserPushPreference> UserPushPreferences => Set<UserPushPreference>();
public DbSet<PushMessage> PushMessages => Set<PushMessage>();
public DbSet<PushDelivery> PushDeliveries => Set<PushDelivery>();
public DbSet<BudgetNotificationReceipt> BudgetNotificationReceipts => Set<BudgetNotificationReceipt>();
protected override void OnModelCreating(ModelBuilder b)
{
@@ -67,6 +72,76 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
b.Entity<AiAvatar>(e => e.HasIndex(x => x.Key).IsUnique());
b.Entity<Sticker>(e => e.HasIndex(x => x.Key).IsUnique());
b.Entity<PushDevice>(e =>
{
e.Property(x => x.InstallationId).HasMaxLength(64);
e.Property(x => x.Provider).HasMaxLength(16);
e.Property(x => x.TokenCiphertext).HasMaxLength(6144);
e.Property(x => x.TokenHash).HasMaxLength(64);
e.Property(x => x.UnbindTokenHash).HasMaxLength(64);
e.Property(x => x.PackageName).HasMaxLength(128);
e.Property(x => x.Flavor).HasMaxLength(24);
e.Property(x => x.AppVersion).HasMaxLength(32);
e.Property(x => x.DisabledReason).HasMaxLength(64);
e.HasIndex(x => new { x.PackageName, x.InstallationId }).IsUnique();
e.HasIndex(x => new { x.Provider, x.PackageName, x.TokenHash }).IsUnique();
e.HasIndex(x => new { x.UserId, x.IsActive });
e.HasIndex(x => x.LastSeenAt);
e.HasOne(x => x.User).WithMany(x => x.PushDevices)
.HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<UserPushPreference>(e =>
{
e.Property(x => x.Category).HasMaxLength(24);
e.HasIndex(x => new { x.UserId, x.Category }).IsUnique();
e.HasOne(x => x.User).WithMany(x => x.PushPreferences)
.HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<PushMessage>(e =>
{
e.Property(x => x.PublicId).HasMaxLength(36);
e.Property(x => x.Source).HasMaxLength(24);
e.Property(x => x.State).HasMaxLength(24);
e.Property(x => x.Category).HasMaxLength(24);
e.Property(x => x.Title).HasMaxLength(80);
e.Property(x => x.Body).HasMaxLength(240);
e.Property(x => x.Action).HasMaxLength(24);
e.Property(x => x.EntityId).HasMaxLength(64);
e.Property(x => x.Flavor).HasMaxLength(24);
e.Property(x => x.ProviderFilter).HasMaxLength(16);
e.HasIndex(x => x.PublicId).IsUnique();
e.HasIndex(x => new { x.State, x.ScheduledAt });
e.HasIndex(x => new { x.TargetUserId, x.CreatedAt });
});
b.Entity<PushDelivery>(e =>
{
e.Property(x => x.Provider).HasMaxLength(16);
e.Property(x => x.State).HasMaxLength(24);
e.Property(x => x.LeaseId).HasMaxLength(36);
e.Property(x => x.ProviderMessageId).HasMaxLength(128);
e.Property(x => x.ErrorCode).HasMaxLength(64);
e.Property(x => x.ErrorMessage).HasMaxLength(400);
e.HasIndex(x => new { x.PushMessageId, x.PushDeviceId }).IsUnique();
e.HasIndex(x => new { x.State, x.NextAttemptAt, x.LeaseExpiresAt });
e.HasOne(x => x.PushMessage).WithMany(x => x.Deliveries)
.HasForeignKey(x => x.PushMessageId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.PushDevice).WithMany(x => x.Deliveries)
.HasForeignKey(x => x.PushDeviceId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<BudgetNotificationReceipt>(e =>
{
e.HasIndex(x => new { x.BudgetId, x.Period, x.Threshold }).IsUnique();
e.HasIndex(x => new { x.UserId, x.Period });
e.HasOne(x => x.User).WithMany()
.HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Budget).WithMany()
.HasForeignKey(x => x.BudgetId).OnDelete(DeleteBehavior.Cascade);
});
// 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>(
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace MiaoJiZhang.Infrastructure.Persistence;
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseMySql(
"Server=127.0.0.1;Database=miaoji_design;User=design;Password=design;",
new MySqlServerVersion(new Version(8, 0, 36)))
.Options;
return new AppDbContext(options);
}
}
@@ -0,0 +1,295 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class VendorPushInfrastructure : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BudgetNotificationReceipts",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<long>(type: "bigint", nullable: false),
BudgetId = table.Column<long>(type: "bigint", nullable: false),
Period = table.Column<int>(type: "int", nullable: false),
Threshold = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BudgetNotificationReceipts", x => x.Id);
table.ForeignKey(
name: "FK_BudgetNotificationReceipts_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BudgetNotificationReceipts_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PushDevices",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<long>(type: "bigint", nullable: false),
InstallationId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Provider = table.Column<string>(type: "varchar(16)", maxLength: 16, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TokenCiphertext = table.Column<string>(type: "varchar(6144)", maxLength: 6144, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TokenHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
UnbindTokenHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
PackageName = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Flavor = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AppVersion = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
VersionCode = table.Column<int>(type: "int", nullable: false),
NotificationsAllowed = table.Column<bool>(type: "tinyint(1)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
DisabledReason = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LastSeenAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PushDevices", x => x.Id);
table.ForeignKey(
name: "FK_PushDevices_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PushMessages",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
PublicId = table.Column<string>(type: "varchar(36)", maxLength: 36, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Source = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
State = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Category = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Title = table.Column<string>(type: "varchar(80)", maxLength: 80, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Body = table.Column<string>(type: "varchar(240)", maxLength: 240, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Action = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EntityId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetUserId = table.Column<long>(type: "bigint", nullable: true),
Flavor = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ProviderFilter = table.Column<string>(type: "varchar(16)", maxLength: 16, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
MinVersionCode = table.Column<int>(type: "int", nullable: true),
MaxVersionCode = table.Column<int>(type: "int", nullable: true),
TtlSeconds = table.Column<int>(type: "int", nullable: false),
IsTest = table.Column<bool>(type: "tinyint(1)", nullable: false),
TestDeviceId = table.Column<long>(type: "bigint", nullable: true),
ScheduledAt = 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),
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PushMessages", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "UserPushPreferences",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<long>(type: "bigint", nullable: false),
Category = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserPushPreferences", x => x.Id);
table.ForeignKey(
name: "FK_UserPushPreferences_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PushDeliveries",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
PushMessageId = table.Column<long>(type: "bigint", nullable: false),
PushDeviceId = table.Column<long>(type: "bigint", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
Provider = table.Column<string>(type: "varchar(16)", maxLength: 16, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
State = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AttemptCount = table.Column<int>(type: "int", nullable: false),
NextAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LeaseId = table.Column<string>(type: "varchar(36)", maxLength: 36, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
LeaseExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ProviderMessageId = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
ErrorCode = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
ErrorMessage = table.Column<string>(type: "varchar(400)", maxLength: 400, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
AcceptedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PushDeliveries", x => x.Id);
table.ForeignKey(
name: "FK_PushDeliveries_PushDevices_PushDeviceId",
column: x => x.PushDeviceId,
principalTable: "PushDevices",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PushDeliveries_PushMessages_PushMessageId",
column: x => x.PushMessageId,
principalTable: "PushMessages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_BudgetNotificationReceipts_BudgetId_Period_Threshold",
table: "BudgetNotificationReceipts",
columns: new[] { "BudgetId", "Period", "Threshold" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_BudgetNotificationReceipts_UserId_Period",
table: "BudgetNotificationReceipts",
columns: new[] { "UserId", "Period" });
migrationBuilder.CreateIndex(
name: "IX_PushDeliveries_PushDeviceId",
table: "PushDeliveries",
column: "PushDeviceId");
migrationBuilder.CreateIndex(
name: "IX_PushDeliveries_PushMessageId_PushDeviceId",
table: "PushDeliveries",
columns: new[] { "PushMessageId", "PushDeviceId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PushDeliveries_State_NextAttemptAt_LeaseExpiresAt",
table: "PushDeliveries",
columns: new[] { "State", "NextAttemptAt", "LeaseExpiresAt" });
migrationBuilder.CreateIndex(
name: "IX_PushDevices_LastSeenAt",
table: "PushDevices",
column: "LastSeenAt");
migrationBuilder.CreateIndex(
name: "IX_PushDevices_PackageName_InstallationId",
table: "PushDevices",
columns: new[] { "PackageName", "InstallationId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PushDevices_Provider_PackageName_TokenHash",
table: "PushDevices",
columns: new[] { "Provider", "PackageName", "TokenHash" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PushDevices_UserId_IsActive",
table: "PushDevices",
columns: new[] { "UserId", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_PushMessages_PublicId",
table: "PushMessages",
column: "PublicId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PushMessages_State_ScheduledAt",
table: "PushMessages",
columns: new[] { "State", "ScheduledAt" });
migrationBuilder.CreateIndex(
name: "IX_PushMessages_TargetUserId_CreatedAt",
table: "PushMessages",
columns: new[] { "TargetUserId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_UserPushPreferences_UserId_Category",
table: "UserPushPreferences",
columns: new[] { "UserId", "Category" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BudgetNotificationReceipts");
migrationBuilder.DropTable(
name: "PushDeliveries");
migrationBuilder.DropTable(
name: "UserPushPreferences");
migrationBuilder.DropTable(
name: "PushDevices");
migrationBuilder.DropTable(
name: "PushMessages");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -199,6 +199,39 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.ToTable("Budgets");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.BudgetNotificationReceipt", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<long>("BudgetId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Period")
.HasColumnType("int");
b.Property<int>("Threshold")
.HasColumnType("int");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId", "Period");
b.HasIndex("BudgetId", "Period", "Threshold")
.IsUnique();
b.ToTable("BudgetNotificationReceipts");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Category", b =>
{
b.Property<long>("Id")
@@ -303,6 +336,271 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.ToTable("Ledgers");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDelivery", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("datetime(6)");
b.Property<int>("AttemptCount")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("ErrorCode")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("ErrorMessage")
.HasMaxLength(400)
.HasColumnType("varchar(400)");
b.Property<DateTime?>("LeaseExpiresAt")
.HasColumnType("datetime(6)");
b.Property<string>("LeaseId")
.HasMaxLength(36)
.HasColumnType("varchar(36)");
b.Property<DateTime>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<string>("ProviderMessageId")
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<long>("PushDeviceId")
.HasColumnType("bigint");
b.Property<long>("PushMessageId")
.HasColumnType("bigint");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("varchar(24)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("PushDeviceId");
b.HasIndex("PushMessageId", "PushDeviceId")
.IsUnique();
b.HasIndex("State", "NextAttemptAt", "LeaseExpiresAt");
b.ToTable("PushDeliveries");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDevice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AppVersion")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisabledReason")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Flavor")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("varchar(24)");
b.Property<string>("InstallationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("datetime(6)");
b.Property<bool>("NotificationsAllowed")
.HasColumnType("tinyint(1)");
b.Property<string>("PackageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<string>("TokenCiphertext")
.IsRequired()
.HasMaxLength(6144)
.HasColumnType("varchar(6144)");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("UnbindTokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<int>("VersionCode")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("LastSeenAt");
b.HasIndex("PackageName", "InstallationId")
.IsUnique();
b.HasIndex("UserId", "IsActive");
b.HasIndex("Provider", "PackageName", "TokenHash")
.IsUnique();
b.ToTable("PushDevices");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushMessage", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("varchar(24)");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("varchar(240)");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("datetime(6)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("varchar(24)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("EntityId")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Flavor")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("varchar(24)");
b.Property<bool>("IsTest")
.HasColumnType("tinyint(1)");
b.Property<int?>("MaxVersionCode")
.HasColumnType("int");
b.Property<int?>("MinVersionCode")
.HasColumnType("int");
b.Property<string>("ProviderFilter")
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<string>("PublicId")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("varchar(36)");
b.Property<DateTime?>("ScheduledAt")
.HasColumnType("datetime(6)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("varchar(24)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("varchar(24)");
b.Property<long?>("TargetUserId")
.HasColumnType("bigint");
b.Property<long?>("TestDeviceId")
.HasColumnType("bigint");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int>("TtlSeconds")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("State", "ScheduledAt");
b.HasIndex("TargetUserId", "CreatedAt");
b.ToTable("PushMessages");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Sticker", b =>
{
b.Property<long>("Id")
@@ -404,47 +702,14 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.HasIndex("LedgerId", "OccurredAt");
b.HasIndex("UserId", "IsDeleted");
b.HasIndex("UserId", "ClientRequestId")
.IsUnique();
b.HasIndex("UserId", "IsDeleted");
b.ToTable("Transactions");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.UserFeaturePermission", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("PermissionKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId", "PermissionKey")
.IsUnique();
b.ToTable("UserFeaturePermissions");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.User", b =>
{
b.Property<long>("Id")
@@ -453,8 +718,11 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AppMode")
.HasColumnType("int");
b.Property<DateTime?>("AccountClosureRequestedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("AccountClosureScheduledAt")
.HasColumnType("datetime(6)");
b.Property<int>("AiChatLimit")
.HasColumnType("int");
@@ -468,15 +736,15 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.Property<DateTime>("AiChatWindowStartedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("AccountClosureRequestedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("AccountClosureScheduledAt")
.HasColumnType("datetime(6)");
b.Property<int>("AppMode")
.HasColumnType("int");
b.Property<string>("AppleUserId")
.HasColumnType("longtext");
b.Property<int>("AuthVersion")
.HasColumnType("int");
b.Property<string>("AvatarUrl")
.HasColumnType("longtext");
@@ -489,9 +757,6 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.Property<bool>("EmailVerified")
.HasColumnType("tinyint(1)");
b.Property<int>("AuthVersion")
.HasColumnType("int");
b.Property<bool>("IsBanned")
.HasColumnType("tinyint(1)");
@@ -542,6 +807,69 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.ToTable("Users");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.UserFeaturePermission", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("PermissionKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId", "PermissionKey")
.IsUnique();
b.ToTable("UserFeaturePermissions");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.UserPushPreference", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("varchar(24)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId", "Category")
.IsUnique();
b.ToTable("UserPushPreferences");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.AiCompanionSetting", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.User", "User")
@@ -564,6 +892,25 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.Navigation("Ledger");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.BudgetNotificationReceipt", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.Budget", "Budget")
.WithMany()
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MiaoJiZhang.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
b.Navigation("User");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Ledger", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.User", "Owner")
@@ -575,6 +922,36 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.Navigation("Owner");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDelivery", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.PushDevice", "PushDevice")
.WithMany("Deliveries")
.HasForeignKey("PushDeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MiaoJiZhang.Domain.Entities.PushMessage", "PushMessage")
.WithMany("Deliveries")
.HasForeignKey("PushMessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PushDevice");
b.Navigation("PushMessage");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDevice", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.User", "User")
.WithMany("PushDevices")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Transaction", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.Category", "Category")
@@ -605,6 +982,17 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.UserPushPreference", b =>
{
b.HasOne("MiaoJiZhang.Domain.Entities.User", "User")
.WithMany("PushPreferences")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Ledger", b =>
{
b.Navigation("Budgets");
@@ -612,6 +1000,16 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.Navigation("Transactions");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDevice", b =>
{
b.Navigation("Deliveries");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushMessage", b =>
{
b.Navigation("Deliveries");
});
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.User", b =>
{
b.Navigation("AiCompanion");
@@ -619,9 +1017,12 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
b.Navigation("FeaturePermissions");
b.Navigation("Ledgers");
b.Navigation("PushDevices");
b.Navigation("PushPreferences");
});
#pragma warning restore 612, 618
}
}
}
+31 -9
View File
@@ -21,8 +21,13 @@ val releaseSigningKeys = listOf(
"keyAlias",
"storeFile",
)
val releaseSigningConfigured = keystorePropertiesFile.exists() &&
releaseSigningKeys.all { !keystoreProperties.getProperty(it).isNullOrBlank() }
val releaseSigningConfigured = keystorePropertiesFile.exists() &&
releaseSigningKeys.all { !keystoreProperties.getProperty(it).isNullOrBlank() }
fun pushBuildValue(name: String): String =
(project.findProperty(name)?.toString() ?: System.getenv(name) ?: "")
.replace("\\", "\\\\")
.replace("\"", "\\\"")
android {
namespace = "com.nx.miaoji"
@@ -44,6 +49,20 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
buildConfigField("String", "PUSH_HUAWEI_APP_ID", "\"${pushBuildValue("PUSH_HUAWEI_APP_ID")}\"")
buildConfigField("String", "PUSH_HONOR_APP_ID", "\"${pushBuildValue("PUSH_HONOR_APP_ID")}\"")
buildConfigField("String", "PUSH_XIAOMI_APP_ID", "\"${pushBuildValue("PUSH_XIAOMI_APP_ID")}\"")
buildConfigField("String", "PUSH_XIAOMI_APP_KEY", "\"${pushBuildValue("PUSH_XIAOMI_APP_KEY")}\"")
buildConfigField("String", "PUSH_OPPO_APP_KEY", "\"${pushBuildValue("PUSH_OPPO_APP_KEY")}\"")
buildConfigField("String", "PUSH_OPPO_APP_SECRET", "\"${pushBuildValue("PUSH_OPPO_APP_SECRET")}\"")
buildConfigField("String", "PUSH_VIVO_APP_ID", "\"${pushBuildValue("PUSH_VIVO_APP_ID")}\"")
buildConfigField("String", "PUSH_VIVO_APP_KEY", "\"${pushBuildValue("PUSH_VIVO_APP_KEY")}\"")
buildConfigField("String", "PUSH_MEIZU_APP_ID", "\"${pushBuildValue("PUSH_MEIZU_APP_ID")}\"")
buildConfigField("String", "PUSH_MEIZU_APP_KEY", "\"${pushBuildValue("PUSH_MEIZU_APP_KEY")}\"")
}
buildFeatures {
buildConfig = true
}
signingConfigs {
@@ -75,9 +94,11 @@ android {
}
buildTypes {
release {}
}
}
release {
proguardFiles("proguard-rules.pro")
}
}
}
val dartDefines = (project.findProperty("dart-defines") as? String)
.orEmpty()
@@ -161,7 +182,8 @@ flutter {
source = "../.."
}
dependencies {
implementation("com.google.mlkit:text-recognition-chinese:16.0.1")
testImplementation("junit:junit:4.13.2")
}
dependencies {
implementation("com.google.mlkit:text-recognition-chinese:16.0.1")
implementation(fileTree(mapOf("dir" to "libs/push", "include" to listOf("*.aar", "*.jar"))))
testImplementation("junit:junit:4.13.2")
}
+7
View File
@@ -0,0 +1,7 @@
# Vendor push SDKs
Place the official Huawei, Honor, Xiaomi, OPPO/Heytap, vivo and Meizu Android
SDK AAR/JAR files in this directory during CI or a local release build. Binary
SDK files are intentionally ignored by git. Public client app IDs and app keys
are injected through the `PUSH_*` Gradle properties or environment variables;
provider master secrets belong only on the API server.
+8
View File
@@ -0,0 +1,8 @@
# Vendor SDK entry points are resolved by VendorPushBridge through reflection.
-keep class com.huawei.hms.aaid.** { *; }
-keep class com.hihonor.push.** { *; }
-keep class com.hihonor.mcs.push.** { *; }
-keep class com.xiaomi.mipush.sdk.** { *; }
-keep class com.heytap.msp.push.** { *; }
-keep class com.vivo.push.** { *; }
-keep class com.meizu.cloud.pushsdk.** { *; }
@@ -30,12 +30,18 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
</activity>
<data android:mimeType="image/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="miaoji" android:host="push" android:pathPrefix="/open"/>
</intent-filter>
</activity>
<activity
android:name=".ProjectionConsentActivity"
@@ -63,7 +63,8 @@ class MainActivity : FlutterActivity() {
private var pendingProgressCount: Int? = null
private var pendingRecognitionAction: Map<String, Any?>? = null
private var recognitionReceiverRegistered = false
private var updateInstallBridge: UpdateInstallBridge? = null
private var updateInstallBridge: UpdateInstallBridge? = null
private var vendorPushBridge: VendorPushBridge? = null
private var pendingSpeechResult: MethodChannel.Result? = null
private var speechRecognizer: SpeechRecognizer? = null
@@ -71,7 +72,11 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
updateInstallBridge = UpdateInstallBridge(this).also { it.register(flutterEngine) }
updateInstallBridge = UpdateInstallBridge(this).also { it.register(flutterEngine) }
vendorPushBridge = VendorPushBridge(this).also {
it.register(flutterEngine)
it.handleIntent(intent)
}
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
channel?.setMethodCallHandler { call, result ->
when (call.method) {
@@ -202,13 +207,15 @@ class MainActivity : FlutterActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
setIntent(intent)
vendorPushBridge?.handleIntent(intent)
handleIncomingIntent(intent)
}
override fun onResume() {
super.onResume()
updateInstallBridge?.onResume()
updateInstallBridge?.onResume()
vendorPushBridge?.onResume()
scheduleShortcutIfNeeded()
dispatchPendingScreenshot()
dispatchPendingRecognitionAction()
@@ -0,0 +1,407 @@
package com.nx.miaoji
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject
import java.lang.reflect.Proxy
import java.util.Locale
import java.util.concurrent.Executors
class VendorPushBridge(private val activity: MainActivity) : MethodChannel.MethodCallHandler {
companion object {
private const val CHANNEL = "com.miaoji/push"
private const val PREFS = "jizhi_vendor_push"
private const val KEY_ENABLED = "enabled"
private const val KEY_TOKEN = "token"
private const val KEY_PROVIDER = "provider"
private const val KEY_PENDING = "pending_open"
}
private val executor = Executors.newSingleThreadExecutor()
private val preferences = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private var channel: MethodChannel? = null
fun register(engine: FlutterEngine) {
channel = MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL).also {
it.setMethodCallHandler(this)
}
dispatchPendingOpen()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getStatus" -> result.success(status())
"enable" -> resolveToken(result)
"refreshToken" -> resolveToken(result)
"disable" -> {
disableProvider()
result.success(status())
}
"openNotificationSettings" -> {
openNotificationSettings()
result.success(true)
}
"getPendingOpen" -> result.success(readPendingOpen())
"acknowledgeOpen" -> {
val messageId = call.argument<String>("messageId")
val pending = readPendingOpen()
if (messageId != null && pending?.get("messageId") == messageId) {
preferences.edit().remove(KEY_PENDING).apply()
}
result.success(true)
}
else -> result.notImplemented()
}
}
fun onResume() {
dispatchPendingOpen()
if (preferences.getBoolean(KEY_ENABLED, false) && cachedToken().isNullOrBlank()) {
resolveToken(null)
}
}
fun handleIntent(intent: Intent?) {
val payload = parseOpen(intent) ?: return
val messageId = payload["messageId"]?.toString().orEmpty()
if (messageId.isBlank()) return
preferences.edit().putString(KEY_PENDING, JSONObject(payload).toString()).apply()
dispatchPendingOpen()
}
private fun resolveToken(result: MethodChannel.Result?) {
createNotificationChannels()
if (!notificationsAllowed()) {
result?.success(status(error = "notification_permission_denied"))
return
}
val provider = detectProvider()
if (provider == null) {
result?.success(status(error = "unsupported_vendor"))
return
}
if (!sdkAvailable(provider)) {
result?.success(status(error = "sdk_not_installed"))
return
}
preferences.edit().putBoolean(KEY_ENABLED, true).putString(KEY_PROVIDER, provider).apply()
executor.execute {
val token = runCatching { registerAndReadToken(provider) }.getOrNull()
if (!token.isNullOrBlank()) {
preferences.edit().putString(KEY_TOKEN, token).putString(KEY_PROVIDER, provider).apply()
activity.runOnUiThread {
channel?.invokeMethod("onToken", mapOf("provider" to provider, "token" to token))
}
}
activity.runOnUiThread {
result?.success(status(error = if (token.isNullOrBlank()) "token_pending" else null))
}
}
}
private fun registerAndReadToken(provider: String): String? = when (provider) {
"huawei" -> huaweiToken()
"honor" -> honorToken()
"xiaomi" -> xiaomiToken()
"oppo" -> oppoToken()
"vivo" -> vivoToken()
"meizu" -> meizuToken()
else -> null
}
private fun huaweiToken(): String? {
val appId = config("PUSH_HUAWEI_APP_ID")
if (appId.isBlank()) return null
val type = Class.forName("com.huawei.hms.aaid.HmsInstanceId")
val instance = type.getMethod("getInstance", Context::class.java).invoke(null, activity)
return type.getMethod("getToken", String::class.java, String::class.java)
.invoke(instance, appId, "HCM") as? String
}
private fun honorToken(): String? {
val appId = config("PUSH_HONOR_APP_ID")
if (appId.isBlank()) return null
val type = firstClass(
"com.hihonor.push.sdk.HonorPushClient",
"com.hihonor.mcs.push.HonorPushClient",
) ?: return null
val instance = invokeMatching(type, null, "getInstance", activity)
?: invokeMatching(type, null, "getInstance")
?: return null
val value = invokeMatching(type, instance, "getPushToken")
?: invokeMatching(type, instance, "getPushToken", appId)
return awaitTaskValue(value)
}
private fun xiaomiToken(): String? {
val appId = config("PUSH_XIAOMI_APP_ID")
val appKey = config("PUSH_XIAOMI_APP_KEY")
if (appId.isBlank() || appKey.isBlank()) return null
val type = Class.forName("com.xiaomi.mipush.sdk.MiPushClient")
invokeMatching(type, null, "registerPush", activity, appId, appKey)
repeat(10) {
val token = invokeMatching(type, null, "getRegId", activity) as? String
if (!token.isNullOrBlank()) return token
Thread.sleep(300)
}
return null
}
private fun oppoToken(): String? {
val appKey = config("PUSH_OPPO_APP_KEY")
val appSecret = config("PUSH_OPPO_APP_SECRET")
if (appKey.isBlank() || appSecret.isBlank()) return null
val type = Class.forName("com.heytap.msp.push.HeytapPushManager")
invokeMatching(type, null, "init", activity.applicationContext, true)
val callbackType = firstClass("com.heytap.msp.push.callback.ICallBackResultService")
val callback = callbackType?.let { dynamicCallback(it) }
if (callback != null) invokeMatching(type, null, "register", activity, appKey, appSecret, callback)
repeat(10) {
val token = invokeMatching(type, null, "getRegisterID") as? String
if (!token.isNullOrBlank()) return token
Thread.sleep(300)
}
return null
}
private fun vivoToken(): String? {
val appId = config("PUSH_VIVO_APP_ID")
val appKey = config("PUSH_VIVO_APP_KEY")
if (appId.isBlank() || appKey.isBlank()) return null
val type = Class.forName("com.vivo.push.PushClient")
val instance = invokeMatching(type, null, "getInstance", activity.applicationContext) ?: return null
invokeMatching(type, instance, "initialize")
val callbackType = firstClass("com.vivo.push.IPushActionListener")
callbackType?.let { invokeMatching(type, instance, "turnOnPush", dynamicCallback(it)) }
repeat(10) {
val token = invokeMatching(type, instance, "getRegId") as? String
if (!token.isNullOrBlank()) return token
Thread.sleep(300)
}
return null
}
private fun meizuToken(): String? {
val appId = config("PUSH_MEIZU_APP_ID")
val appKey = config("PUSH_MEIZU_APP_KEY")
if (appId.isBlank() || appKey.isBlank()) return null
val type = Class.forName("com.meizu.cloud.pushsdk.PushManager")
invokeMatching(type, null, "register", activity.applicationContext, appId, appKey)
repeat(10) {
val token = invokeMatching(type, null, "getPushId", activity.applicationContext) as? String
if (!token.isNullOrBlank()) return token
Thread.sleep(300)
}
return null
}
private fun disableProvider() {
val provider = preferences.getString(KEY_PROVIDER, null)
runCatching {
when (provider) {
"xiaomi" -> invokeMatching(
Class.forName("com.xiaomi.mipush.sdk.MiPushClient"),
null,
"unregisterPush",
activity,
)
"oppo" -> invokeMatching(
Class.forName("com.heytap.msp.push.HeytapPushManager"),
null,
"unRegister",
)
"meizu" -> invokeMatching(
Class.forName("com.meizu.cloud.pushsdk.PushManager"),
null,
"unRegister",
activity.applicationContext,
config("PUSH_MEIZU_APP_ID"),
config("PUSH_MEIZU_APP_KEY"),
)
}
}
preferences.edit().putBoolean(KEY_ENABLED, false).remove(KEY_TOKEN).apply()
}
private fun status(error: String? = null): Map<String, Any?> {
val provider = detectProvider()
return mapOf(
"provider" to provider,
"supported" to (provider != null),
"sdkAvailable" to (provider?.let(::sdkAvailable) == true),
"notificationsAllowed" to notificationsAllowed(),
"enabled" to preferences.getBoolean(KEY_ENABLED, false),
"token" to cachedToken(),
"error" to error,
)
}
private fun detectProvider(): String? {
val value = "${Build.MANUFACTURER} ${Build.BRAND}".lowercase(Locale.ROOT)
return when {
value.contains("honor") -> "honor"
value.contains("huawei") -> "huawei"
value.contains("xiaomi") || value.contains("redmi") || value.contains("poco") -> "xiaomi"
value.contains("oppo") || value.contains("realme") || value.contains("oneplus") -> "oppo"
value.contains("vivo") || value.contains("iqoo") -> "vivo"
value.contains("meizu") -> "meizu"
else -> null
}
}
private fun sdkAvailable(provider: String): Boolean = when (provider) {
"huawei" -> firstClass("com.huawei.hms.aaid.HmsInstanceId") != null
"honor" -> firstClass("com.hihonor.push.sdk.HonorPushClient", "com.hihonor.mcs.push.HonorPushClient") != null
"xiaomi" -> firstClass("com.xiaomi.mipush.sdk.MiPushClient") != null
"oppo" -> firstClass("com.heytap.msp.push.HeytapPushManager") != null
"vivo" -> firstClass("com.vivo.push.PushClient") != null
"meizu" -> firstClass("com.meizu.cloud.pushsdk.PushManager") != null
else -> false
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = activity.getSystemService(NotificationManager::class.java)
manager.createNotificationChannels(
listOf(
NotificationChannel("jizhi_system", "系统通知", NotificationManager.IMPORTANCE_DEFAULT),
NotificationChannel("jizhi_budget", "预算提醒", NotificationManager.IMPORTANCE_HIGH),
NotificationChannel("jizhi_operations", "运营通知", NotificationManager.IMPORTANCE_DEFAULT),
),
)
}
private fun notificationsAllowed(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
private fun openNotificationSettings() {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)
}
activity.startActivity(intent)
}
private fun parseOpen(intent: Intent?): Map<String, Any?>? {
if (intent == null) return null
val data = intent.data
if (data?.scheme == "miaoji" && data.host == "push") {
return mapOf(
"messageId" to data.getQueryParameter("messageId"),
"category" to data.getQueryParameter("category"),
"action" to data.getQueryParameter("action"),
"entityId" to data.getQueryParameter("entityId"),
)
}
val raw = intent.getStringExtra("jz_payload")
?: intent.getStringExtra("action_parameters")
?: return null
return runCatching {
val json = JSONObject(raw)
mapOf(
"messageId" to json.optString("messageId"),
"category" to json.optString("category"),
"action" to json.optString("action"),
"entityId" to json.optString("entityId").ifBlank { null },
)
}.getOrNull()
}
private fun readPendingOpen(): Map<String, Any?>? {
val raw = preferences.getString(KEY_PENDING, null) ?: return null
return runCatching {
val json = JSONObject(raw)
mapOf(
"messageId" to json.optString("messageId"),
"category" to json.optString("category"),
"action" to json.optString("action"),
"entityId" to json.optString("entityId").ifBlank { null },
)
}.getOrNull()
}
private fun dispatchPendingOpen() {
readPendingOpen()?.let { channel?.invokeMethod("onPushOpened", it) }
}
private fun cachedToken(): String? = preferences.getString(KEY_TOKEN, null)
private fun config(name: String): String = when (name) {
"PUSH_HUAWEI_APP_ID" -> BuildConfig.PUSH_HUAWEI_APP_ID
"PUSH_HONOR_APP_ID" -> BuildConfig.PUSH_HONOR_APP_ID
"PUSH_XIAOMI_APP_ID" -> BuildConfig.PUSH_XIAOMI_APP_ID
"PUSH_XIAOMI_APP_KEY" -> BuildConfig.PUSH_XIAOMI_APP_KEY
"PUSH_OPPO_APP_KEY" -> BuildConfig.PUSH_OPPO_APP_KEY
"PUSH_OPPO_APP_SECRET" -> BuildConfig.PUSH_OPPO_APP_SECRET
"PUSH_VIVO_APP_ID" -> BuildConfig.PUSH_VIVO_APP_ID
"PUSH_VIVO_APP_KEY" -> BuildConfig.PUSH_VIVO_APP_KEY
"PUSH_MEIZU_APP_ID" -> BuildConfig.PUSH_MEIZU_APP_ID
"PUSH_MEIZU_APP_KEY" -> BuildConfig.PUSH_MEIZU_APP_KEY
else -> ""
}
private fun firstClass(vararg names: String): Class<*>? =
names.firstNotNullOfOrNull { name -> runCatching { Class.forName(name) }.getOrNull() }
private fun invokeMatching(type: Class<*>, target: Any?, name: String, vararg args: Any?): Any? {
val method = type.methods.firstOrNull { candidate ->
candidate.name == name &&
candidate.parameterTypes.size == args.size &&
candidate.parameterTypes.indices.all { index ->
val argument = args[index]
argument == null || boxed(candidate.parameterTypes[index]).isInstance(argument)
}
} ?: return null
return method.invoke(target, *args)
}
private fun boxed(type: Class<*>): Class<*> = when (type) {
java.lang.Boolean.TYPE -> java.lang.Boolean::class.java
java.lang.Byte.TYPE -> java.lang.Byte::class.java
java.lang.Character.TYPE -> java.lang.Character::class.java
java.lang.Double.TYPE -> java.lang.Double::class.java
java.lang.Float.TYPE -> java.lang.Float::class.java
java.lang.Integer.TYPE -> java.lang.Integer::class.java
java.lang.Long.TYPE -> java.lang.Long::class.java
java.lang.Short.TYPE -> java.lang.Short::class.java
else -> type
}
private fun dynamicCallback(type: Class<*>): Any = Proxy.newProxyInstance(
type.classLoader,
arrayOf(type),
) { _, method, args ->
if (method.name.contains("register", ignoreCase = true)) {
val token = args?.firstOrNull { it is String && it.isNotBlank() } as? String
if (!token.isNullOrBlank()) preferences.edit().putString(KEY_TOKEN, token).apply()
}
null
}
private fun awaitTaskValue(value: Any?): String? {
if (value is String) return value
if (value == null) return null
repeat(20) {
val complete = runCatching {
value.javaClass.getMethod("isComplete").invoke(value) as? Boolean
}.getOrNull()
if (complete == true) {
return runCatching { value.javaClass.getMethod("getResult").invoke(value) as? String }.getOrNull()
}
Thread.sleep(200)
}
return null
}
}
+8 -5
View File
@@ -1,9 +1,12 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
repositories {
google()
mavenCentral()
maven(url = "https://developer.huawei.com/repo/")
maven(url = "https://developer.honor.com/repo")
maven(url = "https://repos.xiaomi.com/maven")
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
+29
View File
@@ -18,6 +18,7 @@ import 'package:miaoji_zhang/features/settings/budget_page.dart';
import 'package:miaoji_zhang/features/settings/category_manage_page.dart';
import 'package:miaoji_zhang/features/settings/companion_page.dart';
import 'package:miaoji_zhang/features/settings/me_page.dart';
import 'package:miaoji_zhang/features/settings/push_settings_page.dart';
import 'package:miaoji_zhang/features/settings/recycle_bin_page.dart';
import 'package:miaoji_zhang/features/settings/recognition_batch_page.dart';
import 'package:miaoji_zhang/features/settings/legal_document_page.dart';
@@ -34,6 +35,7 @@ import 'package:miaoji_zhang/shared/services/recognition_import_service.dart';
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
import 'package:miaoji_zhang/shared/services/sync_service.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/push_service.dart';
import 'package:miaoji_zhang/shared/theme/theme_store.dart';
import 'package:miaoji_zhang/shared/update/update_coordinator.dart';
import 'package:provider/provider.dart';
@@ -70,6 +72,10 @@ final router = GoRouter(
GoRoute(path: '/budget', builder: (_, __) => const BudgetPage()),
GoRoute(path: '/account-data', builder: (_, __) => const AccountDataPage()),
GoRoute(path: '/appearance', builder: (_, __) => const AppearancePage()),
GoRoute(
path: '/notification-settings',
builder: (_, __) => const PushSettingsPage(),
),
GoRoute(path: '/recycle-bin', builder: (_, __) => const RecycleBinPage()),
GoRoute(
path: '/sync-conflicts',
@@ -147,6 +153,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
onError: _handleScreenshotError,
);
ScreenshotChannel.onRecognitionAction(_handleRecognitionAction);
PushService.instance.setOpenHandler(_handlePushOpen);
}
@override
@@ -170,6 +177,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
}
await _runSafely(RecognitionImportService.configureNativeContext);
await _runSafely(RecognitionImportService.importAutomatic);
await _runSafely(PushService.instance.initialize);
if (mounted) setState(() {});
unawaited(_refreshRemoteState());
}
@@ -177,6 +185,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
Future<void> _resumeServices() async {
await _runSafely(RecognitionImportService.configureNativeContext);
await _runSafely(RecognitionImportService.importAutomatic);
await _runSafely(PushService.instance.refresh);
unawaited(_refreshRemoteState());
}
@@ -213,6 +222,26 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
await RecognitionImportService.handleAction(context, action);
}
Future<void> _handlePushOpen(PushOpen open) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final context = _rootNavigatorKey.currentContext;
if (!mounted || context == null || !context.mounted) return;
if (!SessionStore.instance.isAccount && open.action == 'budget') {
router.go('/login', extra: null);
return;
}
switch (open.action) {
case 'home':
router.go('/home');
case 'budget':
router.push('/budget');
case 'update':
await UpdateCoordinator.instance.checkManually(context);
case 'none':
break;
}
}
void _handleSessionExpired() {
final context = _rootNavigatorKey.currentContext;
if (context != null && context.mounted) {
@@ -7,6 +7,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/guest_merge_service.dart';
import 'package:miaoji_zhang/shared/services/local_database.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/push_service.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/version.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
@@ -63,6 +64,7 @@ class _LoginPageState extends State<LoginPage> {
_pass.text,
);
final profile = await AuthApi.me();
await PushService.instance.refresh();
await CurrentLedgerStore.instance.ensureLoaded(force: true);
if (!mounted) return;
if (guestSnapshot?['hasData'] == true) {
@@ -304,6 +304,14 @@ class _MePageState extends State<MePage> {
'外观设置',
onTap: () => context.push('/appearance'),
),
if (session.isAccount)
_row(
AppIcons.bell,
context.jz.primaryBackground,
AppTheme.primary,
'通知设置',
onTap: () => context.push('/notification-settings'),
),
if (session.isAccount &&
SyncService.instance.conflictCount > 0)
_row(
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/services/push_service.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class PushSettingsPage extends StatefulWidget {
const PushSettingsPage({super.key});
@override
State<PushSettingsPage> createState() => _PushSettingsPageState();
}
class _PushSettingsPageState extends State<PushSettingsPage> {
final service = PushService.instance;
@override
void initState() {
super.initState();
service.addListener(_changed);
service.refresh();
}
@override
void dispose() {
service.removeListener(_changed);
super.dispose();
}
void _changed() {
if (mounted) setState(() {});
}
Future<void> _toggle(String category, bool value) async {
final ok = await service.setCategory(category, value);
if (!ok && mounted && service.lastError != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(service.lastError!)));
}
}
@override
Widget build(BuildContext context) {
final status = service.nativeStatus;
return Scaffold(
appBar: AppBar(title: const Text('通知设置')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Card(
child: Column(
children: [
_switchRow(
'系统通知',
'版本更新和重要服务状态',
service.preferences.system,
(value) => _toggle('system', value),
),
const Divider(height: 1),
_switchRow(
'预算提醒',
'预算达到 80% 或 100% 时提醒',
service.preferences.budget,
(value) => _toggle('budget', value),
),
const Divider(height: 1),
_switchRow(
'运营通知',
'活动和产品公告',
service.preferences.operations,
(value) => _toggle('operations', value),
),
],
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'推送通道',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Text(
_providerLabel(status.provider),
style: TextStyle(color: context.jz.text2, fontSize: 12),
),
const SizedBox(height: 4),
Text(
_statusLabel(status),
style: TextStyle(
color: status.notificationsAllowed
? context.jz.text2
: AppTheme.orange,
fontSize: 12,
),
),
if (!status.notificationsAllowed) ...[
const SizedBox(height: 12),
JzActionButton(
label: '打开系统通知设置',
onPressed: service.openNotificationSettings,
secondary: true,
),
],
],
),
),
),
if (service.loading) ...[
const SizedBox(height: 16),
const Center(child: CircularProgressIndicator(strokeWidth: 2)),
],
],
),
);
}
Widget _switchRow(
String title,
String subtitle,
bool value,
ValueChanged<bool> onChanged,
) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 3),
Text(
subtitle,
style: TextStyle(color: context.jz.text2, fontSize: 11.5),
),
],
),
),
Switch(value: value, onChanged: service.loading ? null : onChanged),
],
),
);
String _providerLabel(String? provider) => switch (provider) {
'huawei' => '华为 Push Kit',
'honor' => '荣耀 Push Kit',
'xiaomi' => '小米推送',
'oppo' => 'OPPO 推送',
'vivo' => 'vivo 推送',
'meizu' => '魅族推送',
_ => '当前设备没有可用的国产厂商通道',
};
String _statusLabel(PushNativeStatus status) {
if (!status.supported) return '不支持';
if (!status.sdkAvailable) return '当前安装包未配置对应厂商 SDK';
if (!status.notificationsAllowed) return '系统通知权限已关闭';
if (status.token?.isNotEmpty == true) return '已连接';
if (status.enabled) return '正在获取厂商令牌';
return '未启用';
}
}
+2
View File
@@ -3,6 +3,7 @@ import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/push_service.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class AiCompanion {
@@ -237,6 +238,7 @@ class AuthApi {
static Future<void> logout() async {
CurrentLedgerStore.instance.clear();
await PushService.instance.logout();
await ApiClient.instance.clearToken();
await SessionStore.instance.clearActiveSession();
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:dio/dio.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
class PushPreferences {
final bool system;
final bool budget;
final bool operations;
const PushPreferences({
this.system = false,
this.budget = false,
this.operations = false,
});
bool get anyEnabled => system || budget || operations;
PushPreferences copyWith({bool? system, bool? budget, bool? operations}) =>
PushPreferences(
system: system ?? this.system,
budget: budget ?? this.budget,
operations: operations ?? this.operations,
);
factory PushPreferences.fromJson(Map<String, dynamic> json) =>
PushPreferences(
system: json['system'] as bool? ?? false,
budget: json['budget'] as bool? ?? false,
operations: json['operations'] as bool? ?? false,
);
Map<String, dynamic> toJson() => {
'system': system,
'budget': budget,
'operations': operations,
};
}
class PushRegistration {
final int deviceId;
final String unbindToken;
const PushRegistration({required this.deviceId, required this.unbindToken});
factory PushRegistration.fromJson(Map<String, dynamic> json) =>
PushRegistration(
deviceId: (json['deviceId'] as num).toInt(),
unbindToken: json['unbindToken'] as String,
);
}
class PushApi {
static final Dio _dio = ApiClient.instance.dio;
static Future<PushPreferences> preferences() async {
final response = await _dio.get('/api/push/preferences');
return PushPreferences.fromJson(response.data as Map<String, dynamic>);
}
static Future<PushPreferences> updatePreferences(
PushPreferences preferences,
) async {
final response = await _dio.put(
'/api/push/preferences',
data: preferences.toJson(),
);
return PushPreferences.fromJson(response.data as Map<String, dynamic>);
}
static Future<PushRegistration> registerDevice({
required String installationId,
required String provider,
required String token,
required String packageName,
required String flavor,
required String appVersion,
required int versionCode,
required bool notificationsAllowed,
}) async {
final response = await _dio.put(
'/api/push/devices/$installationId',
data: {
'provider': provider,
'token': token,
'packageName': packageName,
'flavor': flavor,
'appVersion': appVersion,
'versionCode': versionCode,
'notificationsAllowed': notificationsAllowed,
},
);
return PushRegistration.fromJson(response.data as Map<String, dynamic>);
}
static Future<void> unregisterDevice({
required String installationId,
String? unbindToken,
}) async {
await _dio.delete<void>(
'/api/push/devices/$installationId',
options: unbindToken == null
? null
: Options(headers: {'X-Push-Unbind-Token': unbindToken}),
);
}
}
@@ -0,0 +1,338 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/push_api.dart';
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/version.dart';
class PushNativeStatus {
final String? provider;
final bool supported;
final bool sdkAvailable;
final bool notificationsAllowed;
final bool enabled;
final String? token;
final String? error;
const PushNativeStatus({
this.provider,
this.supported = false,
this.sdkAvailable = false,
this.notificationsAllowed = false,
this.enabled = false,
this.token,
this.error,
});
factory PushNativeStatus.fromMap(Map<dynamic, dynamic>? map) =>
PushNativeStatus(
provider: map?['provider'] as String?,
supported: map?['supported'] as bool? ?? false,
sdkAvailable: map?['sdkAvailable'] as bool? ?? false,
notificationsAllowed: map?['notificationsAllowed'] as bool? ?? false,
enabled: map?['enabled'] as bool? ?? false,
token: map?['token'] as String?,
error: map?['error'] as String?,
);
}
class PushOpen {
final String messageId;
final String category;
final String action;
final String? entityId;
const PushOpen({
required this.messageId,
required this.category,
required this.action,
this.entityId,
});
factory PushOpen.fromMap(Map<dynamic, dynamic> map) => PushOpen(
messageId: map['messageId']?.toString() ?? '',
category: map['category']?.toString() ?? 'system',
action: map['action']?.toString() ?? 'none',
entityId: map['entityId']?.toString(),
);
}
class PushService extends ChangeNotifier {
PushService._();
static final instance = PushService._();
static const _channel = MethodChannel('com.miaoji/push');
static const _storage = FlutterSecureStorage();
static const _installationKey = 'push_installation_id';
static const _unbindKey = 'push_unbind_token';
static const _pendingUnbindInstallationKey = 'push_pending_unbind_id';
static const _pendingUnbindTokenKey = 'push_pending_unbind_token';
static const _consumedKey = 'push_consumed_message_ids';
PushPreferences preferences = const PushPreferences();
PushNativeStatus nativeStatus = const PushNativeStatus();
bool loading = false;
bool initialized = false;
String? lastError;
Future<void> Function(PushOpen open)? _openHandler;
void setOpenHandler(Future<void> Function(PushOpen open) handler) {
_openHandler = handler;
}
Future<void> initialize() async {
if (!initialized) {
initialized = true;
_channel.setMethodCallHandler(_handleNativeCall);
}
await _retryPendingUnbind();
await refresh();
try {
final pending = await _channel.invokeMapMethod<dynamic, dynamic>(
'getPendingOpen',
);
if (pending != null) await _handleOpen(PushOpen.fromMap(pending));
} on MissingPluginException {
// Push is Android-only.
}
}
Future<void> refresh() async {
if (!SessionStore.instance.isAccount ||
SessionStore.instance.shouldUseLocalOnly) {
preferences = const PushPreferences();
await _readNativeStatus();
notifyListeners();
return;
}
loading = true;
lastError = null;
notifyListeners();
try {
preferences = await PushApi.preferences();
await _readNativeStatus();
if (preferences.anyEnabled) {
if (nativeStatus.token?.isNotEmpty == true) {
await _register(nativeStatus);
} else if (nativeStatus.notificationsAllowed &&
nativeStatus.supported &&
nativeStatus.sdkAvailable) {
await _refreshNativeToken();
}
}
} catch (error) {
lastError = apiErrorMessage(error);
} finally {
loading = false;
notifyListeners();
}
}
Future<bool> setCategory(String category, bool enabled) async {
if (!SessionStore.instance.isAccount) return false;
loading = true;
lastError = null;
notifyListeners();
try {
if (enabled) {
final granted = await ScreenshotChannel.requestNotificationPermission();
if (!granted) {
await _readNativeStatus();
lastError = '系统通知权限未开启';
return false;
}
}
final next = switch (category) {
'system' => preferences.copyWith(system: enabled),
'budget' => preferences.copyWith(budget: enabled),
'operations' => preferences.copyWith(operations: enabled),
_ => throw ArgumentError.value(category, 'category'),
};
preferences = await PushApi.updatePreferences(next);
if (!preferences.anyEnabled) {
await _unregisterCurrent();
await _invokeNative('disable');
await _readNativeStatus();
} else if (enabled) {
final map = await _channel.invokeMapMethod<dynamic, dynamic>('enable');
nativeStatus = PushNativeStatus.fromMap(map);
if (nativeStatus.token?.isNotEmpty == true) {
await _register(nativeStatus);
} else {
lastError = _statusMessage(nativeStatus);
}
}
return true;
} catch (error) {
lastError = apiErrorMessage(error);
return false;
} finally {
loading = false;
notifyListeners();
}
}
Future<void> openNotificationSettings() =>
_invokeNative('openNotificationSettings');
Future<void> logout() async {
await _unregisterCurrent(queueOnFailure: true);
await _invokeNative('disable');
preferences = const PushPreferences();
nativeStatus = const PushNativeStatus();
notifyListeners();
}
Future<dynamic> _handleNativeCall(MethodCall call) async {
if (call.method == 'onToken') {
final status = PushNativeStatus.fromMap(call.arguments as Map?);
nativeStatus = PushNativeStatus(
provider: status.provider,
supported: true,
sdkAvailable: true,
notificationsAllowed: true,
enabled: true,
token: status.token,
);
if (preferences.anyEnabled && SessionStore.instance.isAccount) {
await _register(nativeStatus);
}
notifyListeners();
} else if (call.method == 'onPushOpened' && call.arguments is Map) {
await _handleOpen(PushOpen.fromMap(call.arguments as Map));
}
}
Future<void> _handleOpen(PushOpen open) async {
if (open.messageId.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final consumed = prefs.getStringList(_consumedKey) ?? <String>[];
if (!consumed.contains(open.messageId)) {
await _openHandler?.call(open);
consumed.add(open.messageId);
if (consumed.length > 50) consumed.removeRange(0, consumed.length - 50);
await prefs.setStringList(_consumedKey, consumed);
}
await _channel.invokeMethod('acknowledgeOpen', {
'messageId': open.messageId,
});
}
Future<void> _readNativeStatus() async {
try {
final map = await _channel.invokeMapMethod<dynamic, dynamic>('getStatus');
nativeStatus = PushNativeStatus.fromMap(map);
} on MissingPluginException {
nativeStatus = const PushNativeStatus(error: 'platform_not_supported');
}
}
Future<void> _refreshNativeToken() async {
final map = await _channel.invokeMapMethod<dynamic, dynamic>(
'refreshToken',
);
nativeStatus = PushNativeStatus.fromMap(map);
if (nativeStatus.token?.isNotEmpty == true) await _register(nativeStatus);
}
Future<void> _register(PushNativeStatus status) async {
final provider = status.provider;
final token = status.token;
if (provider == null || token == null || token.isEmpty) return;
final installationId = await _installationId();
final internal = ApiClient.isInternalBuild;
final registration = await PushApi.registerDevice(
installationId: installationId,
provider: provider,
token: token,
packageName: internal ? 'com.nx.miaoji.internal' : 'com.nx.miaoji',
flavor: internal ? 'internal' : 'production',
appVersion: AppVersion.versionName,
versionCode: AppVersion.buildNumber,
notificationsAllowed: status.notificationsAllowed,
);
await _storage.write(key: _unbindKey, value: registration.unbindToken);
}
Future<void> _unregisterCurrent({bool queueOnFailure = false}) async {
final installationId = await _storage.read(key: _installationKey);
final unbindToken = await _storage.read(key: _unbindKey);
if (installationId == null || unbindToken == null) return;
try {
await PushApi.unregisterDevice(
installationId: installationId,
unbindToken: unbindToken,
);
await _storage.delete(key: _unbindKey);
} catch (_) {
if (queueOnFailure) {
await _storage.write(
key: _pendingUnbindInstallationKey,
value: installationId,
);
await _storage.write(key: _pendingUnbindTokenKey, value: unbindToken);
await _storage.delete(key: _unbindKey);
} else {
rethrow;
}
}
}
Future<void> _retryPendingUnbind() async {
final installationId = await _storage.read(
key: _pendingUnbindInstallationKey,
);
final unbindToken = await _storage.read(key: _pendingUnbindTokenKey);
if (installationId == null || unbindToken == null) return;
try {
await PushApi.unregisterDevice(
installationId: installationId,
unbindToken: unbindToken,
);
await _storage.delete(key: _pendingUnbindInstallationKey);
await _storage.delete(key: _pendingUnbindTokenKey);
} catch (_) {
// Retried on the next launch or resume.
}
}
Future<String> _installationId() async {
final existing = await _storage.read(key: _installationKey);
if (existing != null) return existing;
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String hex(int start, int end) => bytes
.sublist(start, end)
.map((value) => value.toRadixString(16).padLeft(2, '0'))
.join();
final value =
'${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
await _storage.write(key: _installationKey, value: value);
return value;
}
Future<void> _invokeNative(String method) async {
try {
await _channel.invokeMethod(method);
} on MissingPluginException {
// Push is Android-only.
}
}
static String? _statusMessage(PushNativeStatus status) =>
switch (status.error) {
'unsupported_vendor' => '当前设备不支持国产厂商推送',
'sdk_not_installed' => '当前安装包未配置对应厂商推送 SDK',
'token_pending' => '厂商令牌正在生成,请稍后重试',
'notification_permission_denied' => '系统通知权限未开启',
_ => null,
};
}