Compare commits

...
29 Commits
Author SHA1 Message Date
nanxun 477f83c5b1 fix: harden OriginOS recognition tracking 2026-08-31 11:29:04 +08:00
nanxun 9ccbdfead4 feat: refine AI navigation and accessibility recovery 2026-08-26 22:58:26 +08:00
nanxun c6f98b5445 fix: seed production defaults and harden onboarding 2026-08-22 23:18:27 +08:00
nanxun acf6356ba6 fix: save llm key without extra secret 2026-08-22 22:25:07 +08:00
nanxun f8f0b3c4fd feat: reorganize admin llm settings 2026-08-22 22:06:01 +08:00
nanxun 78c42eff53 fix: lower admin password minimum 2026-08-22 21:13:57 +08:00
nanxun 716c2917d4 fix: trigger admin password submission 2026-08-22 21:10:30 +08:00
nanxun dc993a09d6 fix: trigger admin login submission 2026-08-22 20:19:13 +08:00
nanxun 6d2e9c884c ci: fix docker manifest generation 2026-08-21 23:34:20 +08:00
nanxun 011ef82cd0 ci: parse smoke response as json 2026-08-21 23:30:59 +08:00
nanxun 5fbf159369 fix: override backend urls in container 2026-08-21 23:25:29 +08:00
nanxun cceb8a6ae4 fix: bind backend container to port 8080 2026-08-21 23:22:30 +08:00
nanxun af5ca8be73 ci: retry docker builds across proxy outages 2026-08-21 23:18:52 +08:00
nanxun b1ff485bda ci: wait for mysql tcp readiness 2026-08-21 23:16:25 +08:00
nanxun 414e245fb4 ci: account for warm docker cache disk usage 2026-08-21 23:13:32 +08:00
nanxun 85fa02f142 ci: use deterministic smoke test port 2026-08-21 23:12:21 +08:00
nanxun b15fed5b6a ci: support sudo docker on build node 2026-08-21 22:51:30 +08:00
nanxun 613a278505 ci: add x64 backend docker pipeline 2026-08-21 22:12:09 +08:00
nanxun 294a7de61d fix: isolate payment amount evidence 2026-08-21 22:07:45 +08:00
nanxun 979684a06e fix: support offline manual bookkeeping 2026-08-21 15:43:01 +08:00
nanxun 9bb45aac83 ci: archive artifacts before cleanup 2026-08-21 14:21:52 +08:00
nanxun 5f4a1d40ed ci: verify split apk version code 2026-08-21 14:11:35 +08:00
nanxun 5215ea2ea8 ci: build split arm64 apk 2026-08-21 14:00:40 +08:00
nanxun a1433f1f4e ci: enforce arm64 release abi 2026-08-21 13:50:08 +08:00
nanxun 9a8c23e2f0 ci: prime sqlite cache before tests 2026-08-21 13:38:39 +08:00
nanxun 9f87914235 ci: cache verified sqlite native asset 2026-08-21 13:32:43 +08:00
nanxun ef35dc748a ci: persist flutter dependency caches 2026-08-21 13:12:36 +08:00
nanxun ff55623d80 ci: skip cleanup before node allocation 2026-08-21 12:41:53 +08:00
nanxun fefa2d74e6 feat: make core pages local-first offline 2026-08-21 12:40:01 +08:00
74 changed files with 4838 additions and 1046 deletions
+13
View File
@@ -0,0 +1,13 @@
**
!Dockerfile.backend
!admin-web/**
!backend/**
admin-web/node_modules/
admin-web/dist/
admin-web/.env*
backend/**/bin/
backend/**/obj/
backend/**/TestResults/
backend/MiaoJiZhang.Api/wwwroot/assets/
backend/*.log
+10
View File
@@ -33,6 +33,8 @@ frontend/android/app/release/
release/
backups/
filing/
.backup-before-update-*/
.deploy/
*.apk
*.sql
*.pem
@@ -48,6 +50,11 @@ frontend/android/app/libs/push/*.aar
# Local tool metadata
.agents/
.codex/
.codex-build/
.codex-*-a/
.codex-*-b/
.codex-*.patch
backend/.codex-tools/
.git-local/
.idea/
.vscode/
@@ -55,3 +62,6 @@ frontend/android/app/libs/push/*.aar
*.log
*.tmp
*.swp
# Generated Admin Web static assets
backend/MiaoJiZhang.Api/wwwroot/assets/*
+50
View File
@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS admin-build
WORKDIR /src/admin-web
COPY admin-web/package.json admin-web/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund
COPY admin-web/ ./
RUN npm run build
FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS api-build
WORKDIR /src
COPY backend/MiaoJiZhang.sln backend/
COPY backend/MiaoJiZhang.Api/MiaoJiZhang.Api.csproj backend/MiaoJiZhang.Api/
COPY backend/MiaoJiZhang.Domain/MiaoJiZhang.Domain.csproj backend/MiaoJiZhang.Domain/
COPY backend/MiaoJiZhang.Infrastructure/MiaoJiZhang.Infrastructure.csproj backend/MiaoJiZhang.Infrastructure/
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore backend/MiaoJiZhang.Api/MiaoJiZhang.Api.csproj
COPY backend/ backend/
ARG BUILD_VERSION=dev
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish backend/MiaoJiZhang.Api/MiaoJiZhang.Api.csproj \
--configuration Release \
--output /app/publish \
--no-restore \
--property:UseAppHost=false \
--property:InformationalVersion="$BUILD_VERSION" \
&& rm -rf /app/publish/wwwroot \
&& mkdir -p /app/publish/wwwroot
COPY --from=admin-build /src/admin-web/dist/ /app/publish/wwwroot/
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS runtime
ARG BUILD_VERSION=dev
ARG VCS_REF=unknown
ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.title="JiZhi Backend" \
org.opencontainers.image.description="JiZhi admin web and ASP.NET backend" \
org.opencontainers.image.version="$BUILD_VERSION" \
org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.source="https://gitea.nxsir.cn/nanxun/jizhi"
WORKDIR /app
COPY --from=api-build --chown=$APP_UID:$APP_UID /app/publish/ ./
ENV URLS=http://0.0.0.0:8080 \
ASPNETCORE_URLS=http://0.0.0.0:8080 \
ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_EnableDiagnostics=0
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "MiaoJiZhang.Api.dll"]
Vendored
+71 -7
View File
@@ -34,8 +34,10 @@ pipeline {
https_proxy = 'http://192.168.5.200:7890'
NO_PROXY = '127.0.0.1,localhost,192.168.5.8,.nxsir.cn,pub.flutter-io.cn,storage.flutter-io.cn'
no_proxy = '127.0.0.1,localhost,192.168.5.8,.nxsir.cn,pub.flutter-io.cn,storage.flutter-io.cn'
GRADLE_USER_HOME = "${WORKSPACE}/.ci/gradle"
PUB_CACHE = "${WORKSPACE}/.ci/pub-cache"
// Keep dependency caches outside cleanWs. The build node already owns
// these standard user caches, while concurrent builds are disabled.
GRADLE_USER_HOME = '/home/nanxunai/.gradle'
PUB_CACHE = '/home/nanxunai/.pub-cache'
OPENLIST_CREDENTIALS = 'openlist_key'
OPENLIST_BASE_URL = 'https://openlist.nxsir.cn'
OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/jizhang'
@@ -57,7 +59,10 @@ pipeline {
if (versionParts.size() != 2) { error("Invalid pubspec version: ${env.PUBSPEC_VERSION}") }
env.APP_VERSION = versionParts[0]
env.BASE_VERSION_CODE = versionParts[1]
env.ANDROID_VERSION_CODE = "${versionParts[1].toInteger() + env.BUILD_NUMBER.toInteger()}"
env.FLUTTER_BUILD_NUMBER = "${versionParts[1].toInteger() + env.BUILD_NUMBER.toInteger()}"
// Flutter's split-per-abi Gradle configuration assigns arm64-v8a
// the 2xxx versionCode range while preserving the build-number suffix.
env.ANDROID_VERSION_CODE = "${env.FLUTTER_BUILD_NUMBER.toInteger() + 2000}"
env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim()
env.IMMUTABLE_TAG = "${env.APP_VERSION}-internal-b${env.BUILD_NUMBER}-${env.SHORT_SHA}"
env.APK_BASENAME = "JiZhi-${env.IMMUTABLE_TAG}-vc${env.ANDROID_VERSION_CODE}-arm64-v8a.apk"
@@ -95,6 +100,55 @@ PY
set -euo pipefail
"$FLUTTER_ROOT/bin/flutter" config --no-analytics
"$FLUTTER_ROOT/bin/flutter" pub get
'''
sh '''
set -euo pipefail
# sqlite3 native hooks use a workspace-local cache, which cleanWs
# removes. Prime it from a verified node-local cache so a transient
# GitHub connection failure cannot block tests or the Gradle build.
sqlite_package_dir=$(python3 - <<'PY'
import json, pathlib, urllib.parse
config = json.loads(pathlib.Path('.dart_tool/package_config.json').read_text(encoding='utf-8'))
package = next(item for item in config['packages'] if item['name'] == 'sqlite3')
uri = urllib.parse.urlparse(package['rootUri'])
if uri.scheme != 'file':
raise SystemExit(f"unexpected sqlite3 package URI: {package['rootUri']}")
print(pathlib.Path(urllib.parse.unquote(uri.path)).resolve())
PY
)
sqlite_release=$(basename "$sqlite_package_dir")
prime_sqlcipher() {
sqlite_asset="$1"
sqlite_hash=$(sed -n "s/.*'$sqlite_asset': '\\([0-9a-f]\\{64\\}\\)'.*/\\1/p" \
"$sqlite_package_dir/lib/src/hook/asset_hashes.dart")
test "${#sqlite_hash}" -eq 64
sqlite_cache_dir="/home/nanxunai/.cache/jizhang/sqlite3/$sqlite_hash"
sqlite_cache_file="$sqlite_cache_dir/libsqlcipher.so"
sqlite_cache_tmp="$sqlite_cache_file.tmp"
mkdir -p "$sqlite_cache_dir"
if ! printf '%s %s\n' "$sqlite_hash" "$sqlite_cache_file" | sha256sum -c -; then
rm -f "$sqlite_cache_tmp"
curl --fail --location --retry 8 --retry-all-errors --retry-delay 3 \
--connect-timeout 20 --max-time 600 \
--output "$sqlite_cache_tmp" \
"https://github.com/simolus3/sqlite3.dart/releases/download/$sqlite_release/$sqlite_asset"
printf '%s %s\n' "$sqlite_hash" "$sqlite_cache_tmp" | sha256sum -c -
mv -f "$sqlite_cache_tmp" "$sqlite_cache_file"
fi
sqlite_hash_prefix=$(printf '%.8s' "$sqlite_hash")
sqlite_hook_dir=".dart_tool/hooks_runner/shared/sqlite3/build/download-$sqlite_hash_prefix"
mkdir -p "$sqlite_hook_dir"
cp "$sqlite_cache_file" "$sqlite_hook_dir/libsqlcipher.so"
}
prime_sqlcipher libsqlcipher.x64.linux.so
prime_sqlcipher libsqlcipher.arm64.android.so
'''
sh '''
set -euo pipefail
# Keep warnings/errors fatal while allowing the repository's existing
# informational style lints to be cleaned up independently.
"$FLUTTER_ROOT/bin/flutter" analyze --no-pub --no-fatal-infos
@@ -113,12 +167,13 @@ PY
sh '''
set -euo pipefail
"$FLUTTER_ROOT/bin/flutter" build apk \
--flavor internal --release --target-platform android-arm64 \
--build-name "$APP_VERSION" --build-number "$ANDROID_VERSION_CODE" \
--flavor internal --release --target-platform android-arm64 --split-per-abi \
--build-name "$APP_VERSION" --build-number "$FLUTTER_BUILD_NUMBER" \
--dart-define=INTERNAL_BUILD=true \
--dart-define="API_BASE_URL=$API_BASE_URL" \
--dart-define="APP_VERSION=$IMMUTABLE_TAG"
source_apk=build/app/outputs/flutter-apk/app-internal-release.apk
source_apk=$(find build/app/outputs/flutter-apk -maxdepth 1 -type f \
-name '*arm64-v8a*internal*release.apk' -print -quit)
test -s "$source_apk"
cp "$source_apk" "$APK_PATH"
'''
@@ -172,6 +227,15 @@ PY
post {
success { archiveArtifacts artifacts: 'artifacts/*.apk,artifacts/*.sha256,artifacts/*.json', fingerprint: true }
always { cleanWs(deleteDirs: true, notFailBuild: true) }
cleanup {
script {
// A build can be aborted while still waiting for an executor. In that
// case Jenkins has no FilePath context and cleanWs would mask the
// original result with MissingContextVariableException.
if (env.NODE_NAME?.trim()) {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
}
}
+274
View File
@@ -0,0 +1,274 @@
pipeline {
agent {
node {
label '构建机1'
customWorkspace '/home/nanxunai/goujian/workspace/jizhang-backend-docker'
}
}
options {
timestamps()
disableConcurrentBuilds()
skipDefaultCheckout(true)
timeout(time: 120, unit: 'MINUTES')
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '20'))
}
environment {
PATH = '/usr/local/bin:/usr/bin:/bin'
HTTP_PROXY = 'http://192.168.5.200:7890'
HTTPS_PROXY = 'http://192.168.5.200:7890'
http_proxy = 'http://192.168.5.200:7890'
https_proxy = 'http://192.168.5.200:7890'
NO_PROXY = '127.0.0.1,localhost,192.168.5.8,192.168.5.100,.nxsir.cn'
no_proxy = '127.0.0.1,localhost,192.168.5.8,192.168.5.100,.nxsir.cn'
OPENLIST_CREDENTIALS = 'openlist_key'
OPENLIST_BASE_URL = 'https://openlist.nxsir.cn'
OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/jizhang/backend-docker/linux-amd64'
}
stages {
stage('Checkout') {
steps {
deleteDir()
checkout scm
}
}
stage('Metadata And Preflight') {
steps {
script {
env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim()
env.FULL_SHA = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.BUILD_DATE = sh(script: 'date -u +%Y-%m-%dT%H:%M:%SZ', returnStdout: true).trim()
env.BACKEND_VERSION = sh(script: 'date -u +%Y%m%d-%H%M', returnStdout: true).trim()
env.IMMUTABLE_TAG = "${env.BACKEND_VERSION}-b${env.BUILD_NUMBER}-${env.SHORT_SHA}"
env.IMAGE_REF = "jizhi-backend:${env.IMMUTABLE_TAG}"
env.ARCHIVE_BASENAME = "JiZhi-Backend-${env.IMMUTABLE_TAG}-linux-amd64.tar.gz"
env.ARCHIVE_PATH = "${env.WORKSPACE}/artifacts/${env.ARCHIVE_BASENAME}"
env.MANIFEST_PATH = "${env.WORKSPACE}/artifacts/JiZhi-Backend-${env.IMMUTABLE_TAG}-manifest.json"
env.SMOKE_NETWORK = "jizhi-smoke-net-${env.BUILD_NUMBER}"
env.SMOKE_MYSQL = "jizhi-smoke-mysql-${env.BUILD_NUMBER}"
env.SMOKE_APP = "jizhi-smoke-app-${env.BUILD_NUMBER}"
env.DOCKER_CMD = sh(
script: '''
set +x
if docker version >/dev/null 2>&1; then
printf 'docker'
elif sudo -n docker version >/dev/null 2>&1; then
printf 'sudo -n docker'
else
echo 'Jenkins 用户无法访问 Docker;请加入 docker 组或配置免密 sudo docker。' >&2
exit 1
fi
''',
returnStdout: true,
).trim()
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.IMMUTABLE_TAG}"
currentBuild.description = 'linux/amd64 · Admin Web + ASP.NET API'
}
sh '''
set -euo pipefail
test "$(uname -m)" = x86_64
for command_name in git docker curl gzip sha256sum python3; do
command -v "$command_name" >/dev/null
done
$DOCKER_CMD version >/dev/null
$DOCKER_CMD buildx version
test -f Dockerfile.backend
test -f admin-web/package-lock.json
mkdir -p artifacts
available_kb=$(df -Pk "$WORKSPACE" | awk 'NR == 2 { print $4 }')
# Warm Docker layers consume disk but make incremental builds
# much smaller; keep 4 GiB free for image export and smoke tests.
test "$available_kb" -ge 4194304
'''
}
}
stage('Build linux-amd64 Image') {
steps {
sh '''
set -euo pipefail
for attempt in $(seq 1 6); do
if $DOCKER_CMD buildx build \
--platform linux/amd64 \
--load \
--file Dockerfile.backend \
--build-arg "BUILD_VERSION=$IMMUTABLE_TAG" \
--build-arg "VCS_REF=$FULL_SHA" \
--build-arg "BUILD_DATE=$BUILD_DATE" \
--tag "$IMAGE_REF" \
.; then
break
fi
if [ "$attempt" -ge 6 ]; then
exit 1
fi
sleep $((attempt * 10))
done
'''
sh '''
set -euo pipefail
test "$($DOCKER_CMD image inspect "$IMAGE_REF" --format '{{.Os}}/{{.Architecture}}')" = 'linux/amd64'
test "$($DOCKER_CMD image inspect "$IMAGE_REF" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$FULL_SHA"
$DOCKER_CMD run --rm --entrypoint /bin/sh "$IMAGE_REF" -c \
'test -s /app/MiaoJiZhang.Api.dll && test -s /app/wwwroot/index.html'
'''
}
}
stage('Smoke Test Image') {
steps {
sh '''
set -euo pipefail
$DOCKER_CMD network create "$SMOKE_NETWORK" >/dev/null
$DOCKER_CMD run --detach \
--name "$SMOKE_MYSQL" \
--network "$SMOKE_NETWORK" \
--env MYSQL_DATABASE=jizhi_smoke \
--env MYSQL_USER=jizhi \
--env MYSQL_PASSWORD=jizhi-smoke-password \
--env MYSQL_ROOT_PASSWORD=jizhi-smoke-root-password \
mysql:8.4 >/dev/null
mysql_ready=0
for attempt in $(seq 1 60); do
if $DOCKER_CMD exec "$SMOKE_MYSQL" mysqladmin ping \
--host=127.0.0.1 --protocol=tcp \
--user=jizhi --password=jizhi-smoke-password --silent; then
mysql_ready=1
break
fi
sleep 2
done
if [ "$mysql_ready" != 1 ]; then
$DOCKER_CMD logs "$SMOKE_MYSQL" >&2
exit 1
fi
sleep 2
host_port=$((18080 + BUILD_NUMBER % 1000))
$DOCKER_CMD run --detach \
--name "$SMOKE_APP" \
--network "$SMOKE_NETWORK" \
--publish "127.0.0.1:${host_port}:8080" \
--env ASPNETCORE_ENVIRONMENT=Production \
--env "ConnectionStrings__Default=Server=$SMOKE_MYSQL;Port=3306;Database=jizhi_smoke;User=jizhi;Password=jizhi-smoke-password" \
--env Jwt__Secret=jizhi-smoke-jwt-secret-at-least-32-characters \
--env Jwt__Issuer=MiaoJiZhang.Api \
--env Jwt__Audience=MiaoJiZhang.App \
--env Admin__BootstrapUsername=smoke_admin \
--env Admin__BootstrapPassword=jizhi-smoke-admin-password \
--env Admin__CookieSecure=false \
--env "Build__Version=$IMMUTABLE_TAG" \
"$IMAGE_REF" >/dev/null
app_ready=0
for attempt in $(seq 1 90); do
if curl --fail --silent --show-error \
"http://127.0.0.1:$host_port/api/ping" >artifacts/smoke-ping.json; then
app_ready=1
break
fi
if ! $DOCKER_CMD inspect "$SMOKE_APP" --format '{{.State.Running}}' | grep -qx true; then
$DOCKER_CMD logs "$SMOKE_APP" >&2
exit 1
fi
sleep 2
done
if [ "$app_ready" != 1 ]; then
$DOCKER_CMD logs "$SMOKE_APP" >&2
exit 1
fi
python3 - "$IMMUTABLE_TAG" <<'PY'
import json, pathlib, sys
payload = json.loads(pathlib.Path('artifacts/smoke-ping.json').read_text(encoding='utf-8'))
assert payload.get('version') == sys.argv[1], payload
PY
curl --fail --silent --show-error "http://127.0.0.1:$host_port/" \
| grep -Fq '<div id="app"></div>'
'''
}
}
stage('Package Image') {
steps {
sh '''
set -euo pipefail
$DOCKER_CMD save "$IMAGE_REF" | gzip -1 >"$ARCHIVE_PATH"
gzip -t "$ARCHIVE_PATH"
gzip -dc "$ARCHIVE_PATH" | tar -tf - | grep -q '^manifest.json$'
sha256sum "$ARCHIVE_PATH" >"$ARCHIVE_PATH.sha256"
image_id=$($DOCKER_CMD image inspect "$IMAGE_REF" --format '{{.Id}}')
archive_sha=$(awk '{print $1}' "$ARCHIVE_PATH.sha256")
export image_id archive_sha
python3 - <<'PY'
import json, os, pathlib
path = pathlib.Path(os.environ['MANIFEST_PATH'])
payload = {
'project': 'jizhang-backend',
'buildNumber': os.environ['BUILD_NUMBER'],
'version': os.environ['IMMUTABLE_TAG'],
'commit': os.environ['FULL_SHA'],
'platform': 'linux/amd64',
'image': os.environ['IMAGE_REF'],
'imageId': os.environ['image_id'],
'archive': pathlib.Path(os.environ['ARCHIVE_PATH']).name,
'archiveSha256': os.environ['archive_sha'],
'builtAt': os.environ['BUILD_DATE'],
'contents': ['admin-web', 'MiaoJiZhang.Api'],
'smokeTests': ['image architecture', '/api/ping', 'admin web index'],
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + chr(10), encoding='utf-8')
PY
sha256sum "$MANIFEST_PATH" >"$MANIFEST_PATH.sha256"
'''
}
}
stage('Upload To OpenList') {
steps {
withCredentials([usernamePassword(credentialsId: "${OPENLIST_CREDENTIALS}", usernameVariable: 'OPENLIST_USERNAME', passwordVariable: 'OPENLIST_PASSWORD')]) {
sh '''
set -euo pipefail
for artifact in \
"$ARCHIVE_PATH" \
"$ARCHIVE_PATH.sha256" \
"$MANIFEST_PATH" \
"$MANIFEST_PATH.sha256"; do
./scripts/upload-openlist-artifact.sh "$artifact" "$OPENLIST_REMOTE_DIR"
done
'''
}
}
}
}
post {
always {
script {
if (env.NODE_NAME?.trim() && env.DOCKER_CMD?.trim()) {
sh(
script: '''
$DOCKER_CMD rm --force "$SMOKE_APP" "$SMOKE_MYSQL" >/dev/null 2>&1 || true
$DOCKER_CMD network rm "$SMOKE_NETWORK" >/dev/null 2>&1 || true
$DOCKER_CMD image rm "$IMAGE_REF" >/dev/null 2>&1 || true
''',
returnStatus: true,
)
}
}
}
success {
archiveArtifacts artifacts: 'artifacts/*.json,artifacts/*.sha256', fingerprint: true
}
cleanup {
script {
if (env.NODE_NAME?.trim()) {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
}
}
+51 -24
View File
@@ -5,7 +5,7 @@ import { adminAuth } from './auth'
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined,
NotificationOutlined, SafetyCertificateOutlined, AuditOutlined,
LogoutOutlined } from '@ant-design/icons-vue'
LogoutOutlined, CloudServerOutlined, FundOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -14,20 +14,29 @@ const selectedKeys = ref<string[]>([String(route.name)])
watch(() => route.name, (n) => { selectedKeys.value = [String(n)] })
const nav = computed(() => [
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
{ key: 'Settings', icon: ControlOutlined, label: '系统设置' },
{ key: 'Configs', icon: SettingOutlined, label: '品牌配置' },
{ key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' },
{ key: 'Personas', icon: SmileOutlined, label: 'AI 性格' },
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
...(adminAuth.identity.value?.role === 'super_admin' ? [
const navGroups = computed(() => [
{ label: '概览', items: [
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
] },
{ label: 'AI 配置', items: [
{ key: 'ModelService', icon: CloudServerOutlined, label: '模型服务' },
{ key: 'Personas', icon: SmileOutlined, label: 'AI 性格' },
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
] },
{ label: '产品配置', items: [
{ key: 'ProductBasic', icon: SettingOutlined, label: '品牌与基础设置' },
{ key: 'FeatureLimits', icon: ControlOutlined, label: '功能与额度' },
{ key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' },
] },
{ label: '运营', items: [
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
] },
...(adminAuth.identity.value?.role === 'super_admin' ? [{ label: '安全', items: [
{ key: 'AdminAccounts', icon: SafetyCertificateOutlined, label: '管理员账号' },
{ key: 'Audit', icon: AuditOutlined, label: '操作审计' },
] : []),
] }] : []),
])
watch(adminAuth.identity, value => {
@@ -43,20 +52,23 @@ async function logout() {
<template>
<router-view v-if="route.meta.public || route.name === 'ChangePassword'" />
<a-layout v-else style="min-height: 100vh">
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="200"
style="border-right: 1px solid #f0f0f0">
<div style="padding: 18px 20px; font-size: 16px; font-weight: 700; white-space: nowrap; overflow: hidden;">
<span style="color:#25211E;margin-right:6px"></span>记之 Admin
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="224"
breakpoint="lg" class="app-sider">
<div class="brand-lockup">
<FundOutlined />
<span>记之 Admin</span>
</div>
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" :style="{ borderRight: 0 }"
@click="({key}: {key: string}) => router.push({name: key})">
<a-menu-item v-for="n in nav" :key="n.key">
<component :is="n.icon" />
<span>{{ n.label }}</span>
</a-menu-item>
<a-menu-item-group v-for="group in navGroups" :key="group.label" :title="group.label">
<a-menu-item v-for="item in group.items" :key="item.key">
<component :is="item.icon" />
<span>{{ item.label }}</span>
</a-menu-item>
</a-menu-item-group>
</a-menu>
<div style="position:absolute;bottom:16px;left:16px;right:16px">
<a-button type="text" block style="text-align:left" @click="logout">
<div class="logout-area">
<a-button type="text" block @click="logout">
<template #icon><LogoutOutlined /></template>退出登录
</a-button>
</div>
@@ -68,9 +80,24 @@ async function logout() {
<a-tag>{{ adminAuth.identity.value?.role }}</a-tag>
</a-space>
</a-layout-header>
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 8px; min-height: 360px;">
<a-layout-content class="app-content">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
.app-sider { position: sticky; top: 0; height: 100vh; overflow: auto; border-right: 1px solid #eef0f3; }
.brand-lockup { display: flex; align-items: center; gap: 10px; height: 60px; padding: 0 22px; overflow: hidden; color: #191f26; font-size: 16px; font-weight: 700; white-space: nowrap; }
.brand-lockup :first-child { color: #00a67d; font-size: 20px; }
.logout-area { position: sticky; bottom: 0; padding: 12px 16px 16px; background: #fff; }
.logout-area .ant-btn { text-align: left; }
.app-content { min-height: 360px; margin: 18px 20px; padding: 24px; border-radius: 14px; background: #fff; }
:deep(.ant-menu-item-group-title) { padding: 18px 24px 6px; color: #8b949e; font-size: 11px; font-weight: 600; letter-spacing: .08em; }
:deep(.ant-menu-item) { min-height: 42px; }
:deep(.ant-layout-sider-collapsed .ant-menu-item-group-title) { height: 12px; padding: 6px 0; overflow: hidden; color: transparent; }
@media (max-width: 760px) {
.app-content { margin: 10px; padding: 16px; }
}
</style>
+10
View File
@@ -56,6 +56,16 @@ export const api = {
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}`),
llmSettings: () => http.get('/api/admin/llm/settings').then(r => r.data),
updateLlmSettings: (data: {
protocol: string
baseUrl: string
model: string
maxTokens: number
temperature: number
}) => http.put('/api/admin/llm/settings', data).then(r => r.data),
updateLlmApiKey: (apiKey: string) => http.put('/api/admin/llm/api-key', { apiKey }).then(r => r.data),
deleteLlmApiKey: () => http.delete('/api/admin/llm/api-key').then(r => r.data),
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),
@@ -0,0 +1,85 @@
import { onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { api } from '../api'
interface Config {
id: number
key: string
value: string
version: number
}
export function useAdminConfigs(defaults: Record<string, string>) {
const configs = ref<Record<string, Config>>({})
const loading = ref(true)
const saving = ref(false)
onMounted(load)
async function load() {
loading.value = true
try {
const list = await api.configs() as Config[]
const map: Record<string, Config> = {}
for (const config of list) map[config.key] = config
for (const [key, value] of Object.entries(defaults)) {
if (!map[key]) map[key] = { id: 0, key, value, version: 0 }
}
configs.value = map
} catch (error: any) {
message.error(readError(error, '配置加载失败'))
} finally {
loading.value = false
}
}
function value(key: string) {
return configs.value[key]?.value ?? defaults[key] ?? ''
}
function numberValue(key: string) {
const parsed = Number(value(key))
return Number.isFinite(parsed) ? parsed : Number(defaults[key] ?? 0)
}
function boolValue(key: string) {
return value(key) === 'true'
}
function setValue(key: string, next: string | number | boolean | null) {
const normalized = String(next ?? '')
const current = configs.value[key]
if (current) current.value = normalized
else configs.value[key] = { id: 0, key, value: normalized, version: 0 }
}
async function save(keys: string[]) {
saving.value = true
try {
for (const key of keys) {
const config = configs.value[key]
if (!config) continue
const saved = config.id > 0
? await api.updateConfig(config.id, config.value)
: await api.createConfig(config.key, config.value)
config.id = saved.id
config.version = saved.version
}
message.success('配置已保存')
} catch (error: any) {
message.error(readError(error, '配置保存失败'))
throw error
} finally {
saving.value = false
}
}
return { loading, saving, value, numberValue, boolValue, setValue, save, load }
}
export function readError(error: any, fallback: string) {
return error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
error?.message || fallback
}
+5 -2
View File
@@ -8,8 +8,11 @@ const router = createRouter({
{ path: '/change-password', name: 'ChangePassword', component: () => import('../views/ChangePassword.vue') },
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
{ path: '/settings', name: 'Settings', component: () => import('../views/Settings.vue') },
{ path: '/configs', name: 'Configs', component: () => import('../views/Configs.vue') },
{ path: '/settings', redirect: '/ai/model' },
{ path: '/configs', redirect: '/product/basic' },
{ path: '/ai/model', name: 'ModelService', component: () => import('../views/ModelService.vue') },
{ path: '/product/basic', name: 'ProductBasic', component: () => import('../views/ProductBasic.vue') },
{ path: '/product/features', name: 'FeatureLimits', component: () => import('../views/FeatureLimits.vue') },
{ path: '/categories', name: 'SysCategories', component: () => import('../views/SysCategories.vue') },
{ path: '/personas', name: 'Personas', component: () => import('../views/Personas.vue') },
{ path: '/avatars', name: 'Avatars', component: () => import('../views/Avatars.vue') },
+6 -4
View File
@@ -28,7 +28,7 @@ async function load() {
}
async function createAccount() {
if (form.password.length < 12) return message.error('初始密码至少 12 位')
if (form.password.length < 6 || form.password.length > 128) return message.error('初始密码必须为 6 到 128 位')
await api.createAdminAccount(form)
message.success('管理员已创建')
createOpen.value = false
@@ -50,7 +50,9 @@ async function update(account: Account, patch: Partial<Account>) {
}
async function submitReset() {
if (!resetTarget.value || resetPassword.value.length < 12) return message.error('新密码至少 12 位')
if (!resetTarget.value || resetPassword.value.length < 6 || resetPassword.value.length > 128) {
return message.error('新密码必须为 6 到 128 位')
}
await api.resetAdminPassword(resetTarget.value.id, resetPassword.value)
message.success('密码已重置,现有会话已撤销')
resetTarget.value = null
@@ -110,7 +112,7 @@ onMounted(load)
<a-modal v-model:open="createOpen" title="新建管理员" ok-text="创建" @ok="createAccount">
<a-form layout="vertical">
<a-form-item label="用户名"><a-input v-model:value="form.username" /></a-form-item>
<a-form-item label="初始密码"><a-input-password v-model:value="form.password" /></a-form-item>
<a-form-item label="初始密码"><a-input-password v-model:value="form.password" :maxlength="128" /></a-form-item>
<a-form-item label="角色">
<a-select v-model:value="form.role">
<a-select-option value="operator">operator</a-select-option>
@@ -123,7 +125,7 @@ onMounted(load)
<a-modal :open="!!resetTarget" title="重置密码" ok-text="重置并撤销会话"
@cancel="resetTarget = null" @ok="submitReset">
<a-input-password v-model:value="resetPassword" placeholder="至少 12 位的新密码" />
<a-input-password v-model:value="resetPassword" :maxlength="128" placeholder="6 到 128 位的新密码" />
</a-modal>
</template>
+13 -4
View File
@@ -9,7 +9,11 @@ const form = reactive({ currentPassword: '', newPassword: '', confirmPassword: '
const loading = ref(false)
async function submit() {
if (form.newPassword.length < 12) return message.error('新密码至少 12 位')
if (loading.value) return
if (!form.currentPassword) return message.error('请输入当前密码')
if (form.newPassword.length < 6 || form.newPassword.length > 128) {
return message.error('新密码长度必须为 6 到 128 位')
}
if (form.newPassword !== form.confirmPassword) return message.error('两次输入的新密码不一致')
loading.value = true
try {
@@ -39,14 +43,19 @@ async function logout() {
<a-input-password v-model:value="form.currentPassword" autocomplete="current-password" />
</a-form-item>
<a-form-item label="新密码" required>
<a-input-password v-model:value="form.newPassword" autocomplete="new-password" />
<a-input-password v-model:value="form.newPassword" autocomplete="new-password" :maxlength="128" />
</a-form-item>
<a-form-item label="确认新密码" required>
<a-input-password v-model:value="form.confirmPassword" autocomplete="new-password" />
<a-input-password
v-model:value="form.confirmPassword"
autocomplete="new-password"
:maxlength="128"
@pressEnter="submit"
/>
</a-form-item>
<a-space style="width:100%;justify-content:flex-end">
<a-button @click="logout">退出登录</a-button>
<a-button type="primary" html-type="submit" :loading="loading">保存密码</a-button>
<a-button type="primary" html-type="submit" :loading="loading" @click="submit">保存密码</a-button>
</a-space>
</a-form>
</section>
-117
View File
@@ -1,117 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { api } from '../api'
import { message } from 'ant-design-vue'
import { formatShanghaiDate } from '../utils/time'
interface Config { id: number; key: string; value: string; version: number; updatedAt: string }
const list = ref<Config[]>([])
const loading = ref(true)
// Grouped by prefix
const configMeta: Record<string, { label: string; desc: string; type: 'text' | 'number' | 'url' | 'switch' | 'select'; options?: string[] }> = {
'brand.app_name': { label: 'App 名称', desc: 'App 内展示名称', type: 'text' },
'brand.slogan': { label: 'App 标语', desc: '启动页/关于页口号', type: 'text' },
'brand.logo_url': { label: 'Logo URL', desc: '品牌 Logo 远程地址', type: 'url' },
'limit.daily_ai_messages': { label: '全局日限额', desc: '全站每日 AI 消息上限', type: 'number' },
'limit.daily_ai_messages_per_user': { label: '每人日限额', desc: '单用户每日 AI 消息上限', type: 'number' },
'limit.max_monthly_budget': { label: '最大月预算', desc: '用户可设置的最高月预算金额', type: 'number' },
'feature.ocr_enabled': { label: 'OCR 拍照识别', desc: '是否开放 OCR 小票识别功能', type: 'switch' },
'feature.voice_enabled': { label: '语音输入', desc: '是否开放语音记账功能', type: 'switch' },
'feature.ai_auto_book': { label: 'AI 自动入账', desc: 'AI 识别记账意图后是否直接写库', type: 'switch' },
'feature.sticker_enabled': { label: '表情包功能', desc: '是否开放表情包面板和 AI 表情回复', type: 'switch' },
'system.default_ledger_name': { label: '默认账本名', desc: '新用户注册时自动创建', type: 'text' },
'system.max_ledgers_per_user': { label: '每人最多账本', desc: '单用户可创建账本上限', type: 'number' },
}
const groups = [
{ key: 'brand', label: '品牌', prefix: 'brand.' },
{ key: 'limit', label: '限额', prefix: 'limit.' },
{ key: 'feature', label: '功能开关', prefix: 'feature.' },
{ key: 'system', label: '系统', prefix: 'system.' },
]
const grouped = computed(() => {
const map: Record<string, Config[]> = {}
for (const cfg of list.value) {
const g = groups.find(g => cfg.key.startsWith(g.prefix))
const k = g?.key || 'other'
if (!map[k]) map[k] = []
map[k].push(cfg)
}
return map
})
onMounted(refresh)
async function refresh() { loading.value = true; try { list.value = await api.configs() } finally { loading.value = false } }
const editVisible = ref(false)
const editItem = ref<Config | null>(null)
const editValue = ref('')
const editingMeta = computed(() => editItem.value ? configMeta[editItem.value.key] : null)
function openEdit(cfg: Config) { editItem.value = cfg; editValue.value = cfg.value; editVisible.value = true }
async function saveEdit() {
if (!editItem.value) return
await api.updateConfig(editItem.value.id, editValue.value)
message.success(`已更新 ${editItem.value.key}`)
editVisible.value = false
refresh()
}
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<h2 style="margin:0">品牌配置</h2>
<a-button @click="refresh">刷新</a-button>
</div>
<a-tabs>
<a-tab-pane v-for="g in groups" :key="g.key" :tab="g.label">
<a-row :gutter="[16,12]">
<a-col v-for="cfg in grouped[g.key]" :key="cfg.id" :span="8">
<a-card size="small" hoverable @click="openEdit(cfg)">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px">{{ configMeta[cfg.key]?.label || cfg.key }}</div>
<div style="color:#999;font-size:11px;margin-bottom:6px">{{ configMeta[cfg.key]?.desc || '' }}</div>
</div>
<a-tag color="blue" style="margin-left:8px">v{{ cfg.version }}</a-tag>
</div>
<div v-if="configMeta[cfg.key]?.type === 'switch'"
style="margin-top:6px;font-size:18px">
<span v-if="cfg.value === 'true'" style="color:#00B386"> 已开启</span>
<span v-else style="color:#ccc"> 已关闭</span>
</div>
<div v-else style="margin-top:6px;font-size:16px;font-weight:700;word-break:break-all">
{{ cfg.key.includes('key') ? '••••••••' : cfg.value || '(空)' }}
</div>
<div style="color:#999;font-size:10px;margin-top:4px">{{ formatShanghaiDate(cfg.updatedAt) }}</div>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
<a-modal v-model:open="editVisible" :title="`编辑配置: ${editItem?.key}`" @ok="saveEdit" :width="440">
<div style="margin-bottom:10px;color:#999;font-size:12px">{{ editingMeta?.desc }}</div>
<template v-if="editingMeta?.type === 'switch'">
<a-switch
:checked="editValue === 'true'"
@change="(v: boolean) => editValue = String(v)"
checked-children="开启" un-checked-children="关闭" />
</template>
<template v-else-if="editingMeta?.type === 'number'">
<a-input-number v-model:value="(editValue as any)" style="width:100%" />
</template>
<template v-else-if="editingMeta?.type === 'select' && editingMeta.options">
<a-select v-model:value="editValue" style="width:100%" :options="editingMeta.options.map(o=>({value:o,label:o}))" />
</template>
<template v-else>
<a-input v-model:value="editValue" />
</template>
</a-modal>
</div>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ControlOutlined, SafetyCertificateOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
import { useAdminConfigs } from '../composables/useAdminConfigs'
const limitKeys = ['limit.daily_ai_messages', 'limit.daily_ai_messages_per_user', 'limit.max_monthly_budget']
const featureKeys = ['feature.ocr_enabled', 'feature.voice_enabled', 'feature.ai_auto_book', 'feature.sticker_enabled', 'feature.screenshot_bookkeeping_enabled']
const onboardingKeys = ['permission.default.ai_enabled', 'quota.default_ai_chat_limit', 'quota.default_ai_chat_period']
const keys = [...limitKeys, ...featureKeys, ...onboardingKeys]
const config = useAdminConfigs({
'limit.daily_ai_messages': '200',
'limit.daily_ai_messages_per_user': '50',
'limit.max_monthly_budget': '99999999',
'feature.ocr_enabled': 'true',
'feature.voice_enabled': 'true',
'feature.ai_auto_book': 'true',
'feature.sticker_enabled': 'true',
'feature.screenshot_bookkeeping_enabled': 'true',
'permission.default.ai_enabled': 'true',
'quota.default_ai_chat_limit': '50',
'quota.default_ai_chat_period': 'day',
})
const features = [
{ key: 'feature.ocr_enabled', label: 'OCR 小票识别', detail: '允许拍照提取账单信息' },
{ key: 'feature.voice_enabled', label: '语音记账', detail: '允许使用语音输入记账' },
{ key: 'feature.ai_auto_book', label: 'AI 自动入账', detail: '识别到明确记账意图后直接入账' },
{ key: 'feature.sticker_enabled', label: '表情包功能', detail: '开放聊天表情面板与 AI 表情回复' },
{ key: 'feature.screenshot_bookkeeping_enabled', label: '截图自动记账', detail: '允许 Android 截图识别与自动记账' },
]
</script>
<template>
<div class="settings-page">
<header class="page-heading">
<div><h1>功能与额度</h1><p>集中管理能力开关全局成本阈值和新用户默认 AI 权限</p></div>
<a-button type="primary" :loading="config.saving.value" @click="config.save(keys)">保存设置</a-button>
</header>
<a-skeleton v-if="config.loading.value" active :paragraph="{ rows: 10 }" />
<section v-else class="settings-panel">
<div class="section-heading"><ThunderboltOutlined /><div><h2>功能开关</h2><p>关闭后对应入口和服务能力将不可用</p></div></div>
<div class="switch-list">
<div v-for="item in features" :key="item.key" class="switch-row">
<div><strong>{{ item.label }}</strong><span>{{ item.detail }}</span></div>
<a-switch :checked="config.boolValue(item.key)" @change="(value: boolean) => config.setValue(item.key, value)" />
</div>
</div>
<a-divider />
<div class="section-heading"><ControlOutlined /><div><h2>全局限制</h2><p>限制 AI 调用量和用户可设置的预算边界</p></div></div>
<a-form layout="vertical" class="form-grid">
<a-form-item label="全站每日 AI 消息上限">
<a-input-number :value="config.numberValue('limit.daily_ai_messages')" :min="0" style="width:100%" @change="(value: number | null) => config.setValue('limit.daily_ai_messages', value)" />
</a-form-item>
<a-form-item label="每人每日 AI 消息上限">
<a-input-number :value="config.numberValue('limit.daily_ai_messages_per_user')" :min="0" style="width:100%" @change="(value: number | null) => config.setValue('limit.daily_ai_messages_per_user', value)" />
</a-form-item>
<a-form-item label="最大月预算金额">
<a-input-number :value="config.numberValue('limit.max_monthly_budget')" :min="0" style="width:100%" @change="(value: number | null) => config.setValue('limit.max_monthly_budget', value)" />
</a-form-item>
</a-form>
<a-divider />
<div class="section-heading"><SafetyCertificateOutlined /><div><h2>新用户 AI 权限</h2><p>只应用于保存后注册的新用户现有用户权限不变</p></div></div>
<a-form layout="vertical" class="form-grid">
<a-form-item label="默认启用 AI">
<a-switch :checked="config.boolValue('permission.default.ai_enabled')" @change="(value: boolean) => config.setValue('permission.default.ai_enabled', value)" />
</a-form-item>
<a-form-item label="默认对话次数" extra="0 表示不限次数。">
<a-input-number :value="config.numberValue('quota.default_ai_chat_limit')" :min="0" :max="1000000" style="width:100%" @change="(value: number | null) => config.setValue('quota.default_ai_chat_limit', value)" />
</a-form-item>
<a-form-item label="额度重置周期">
<a-select :value="config.value('quota.default_ai_chat_period')" @change="(value: string) => config.setValue('quota.default_ai_chat_period', value)">
<a-select-option value="day">每天</a-select-option>
<a-select-option value="week">每周周一开始</a-select-option>
<a-select-option value="month">每月</a-select-option>
</a-select>
</a-form-item>
</a-form>
</section>
</div>
</template>
<style scoped>
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
.page-heading h1 { margin: 0 0 6px; font-size: 24px; }
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; }
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; }
.section-heading > :first-child { margin-top: 3px; color: #00a67d; font-size: 20px; }
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
.switch-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 16px; }
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 16px; border-radius: 12px; background: #f6f7f9; }
.switch-row strong, .switch-row span { display: block; }
.switch-row span { margin-top: 3px; color: #5e6772; font-size: 12px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
@media (max-width: 760px) { .page-heading { flex-direction: column; } .switch-list, .form-grid { grid-template-columns: 1fr; } .settings-panel { padding: 18px; } }
</style>
+14 -4
View File
@@ -10,11 +10,16 @@ const loading = ref(false)
const error = ref('')
async function submit() {
if (!form.username.trim() || !form.password) return
if (loading.value) return
const username = form.username.trim()
if (!username || !form.password) {
error.value = '请输入用户名和密码'
return
}
loading.value = true
error.value = ''
try {
const user = await adminAuth.login(form.username, form.password)
const user = await adminAuth.login(username, form.password)
if (user.mustChangePassword) {
await router.replace('/change-password')
} else {
@@ -40,9 +45,14 @@ async function submit() {
<a-input v-model:value="form.username" autocomplete="username" size="large" autofocus />
</a-form-item>
<a-form-item label="密码" required>
<a-input-password v-model:value="form.password" autocomplete="current-password" size="large" />
<a-input-password
v-model:value="form.password"
autocomplete="current-password"
size="large"
@pressEnter="submit"
/>
</a-form-item>
<a-button type="primary" html-type="submit" size="large" block :loading="loading">登录</a-button>
<a-button type="primary" html-type="submit" size="large" block :loading="loading" @click="submit">登录</a-button>
</a-form>
</section>
</main>
+243
View File
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { CloudServerOutlined, KeyOutlined } from '@ant-design/icons-vue'
import { api } from '../api'
import { readError } from '../composables/useAdminConfigs'
interface ApiKeyState {
configured: boolean
masked: string
source: 'database' | 'environment' | 'none'
canManage: boolean
}
interface LlmSettings {
protocol: string
baseUrl: string
model: string
maxTokens: number
temperature: number
apiKey: ApiKeyState
}
const loading = ref(true)
const saving = ref(false)
const testing = ref(false)
const savingKey = ref(false)
const deletingKey = ref(false)
const apiKeyInput = ref('')
const testResult = ref<{ ok: boolean; detail: string } | null>(null)
const keyState = ref<ApiKeyState>({ configured: false, masked: '', source: 'none', canManage: false })
const form = reactive({
protocol: 'responses',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
maxTokens: 1024,
temperature: 0.7,
})
const protocols = [
{ value: 'responses', label: 'Responses', detail: 'OpenAI 新版接口,支持 Agent 工具调用' },
{ value: 'chat_completions', label: 'Chat Completions', detail: '兼容 OpenAI 风格的聊天补全服务' },
{ value: 'messages', label: 'Messages', detail: '兼容 Anthropic Claude Messages API' },
]
onMounted(load)
async function load() {
loading.value = true
try { apply(await api.llmSettings()) }
catch (error: any) { message.error(readError(error, '模型配置加载失败')) }
finally { loading.value = false }
}
function apply(settings: LlmSettings) {
form.protocol = settings.protocol || 'responses'
form.baseUrl = settings.baseUrl || 'https://api.openai.com/v1'
form.model = settings.model || 'gpt-4o-mini'
form.maxTokens = settings.maxTokens ?? 1024
form.temperature = settings.temperature ?? 0.7
keyState.value = settings.apiKey
}
async function saveSettings(showSuccess = true) {
saving.value = true
try {
apply(await api.updateLlmSettings({ ...form }))
if (showSuccess) message.success('模型参数已保存')
} catch (error: any) {
message.error(readError(error, '模型参数保存失败'))
throw error
} finally { saving.value = false }
}
async function saveApiKey() {
if (!apiKeyInput.value.trim()) {
message.warning('请输入新的 API Key')
return
}
savingKey.value = true
try {
apply(await api.updateLlmApiKey(apiKeyInput.value))
apiKeyInput.value = ''
message.success('API Key 已加密保存')
} catch (error: any) {
message.error(readError(error, 'API Key 保存失败'))
} finally { savingKey.value = false }
}
async function deleteApiKey() {
deletingKey.value = true
try {
apply(await api.deleteLlmApiKey())
message.success(keyState.value.source === 'environment'
? '数据库密钥已删除,当前回退使用环境变量'
: 'API Key 已删除')
} catch (error: any) {
message.error(readError(error, 'API Key 删除失败'))
} finally { deletingKey.value = false }
}
async function testConnection() {
testing.value = true
testResult.value = null
try {
await saveSettings(false)
const result = await api.testLlm()
testResult.value = { ok: result.ok, detail: result.detail || result.error || '' }
result.ok ? message.success('LLM 连接正常') : message.error(result.error || '连接失败')
} catch (error: any) {
testResult.value = { ok: false, detail: readError(error, '连接失败') }
} finally { testing.value = false }
}
function sourceLabel(source: ApiKeyState['source']) {
if (source === 'database') return '后台加密配置'
if (source === 'environment') return '环境变量回退'
return '未配置'
}
</script>
<template>
<div class="settings-page">
<header class="page-heading">
<div>
<h1>模型服务</h1>
<p>配置 AI 服务连接模型参数和访问密钥测试连接会先保存当前模型参数</p>
</div>
<a-space>
<a-button :loading="testing" @click="testConnection">测试连接</a-button>
<a-button type="primary" :loading="saving" @click="saveSettings()">保存参数</a-button>
</a-space>
</header>
<a-skeleton v-if="loading" active :paragraph="{ rows: 9 }" />
<template v-else>
<a-alert
v-if="testResult"
:type="testResult.ok ? 'success' : 'error'"
:message="testResult.ok ? '连接成功' : '连接失败'"
:description="testResult.detail"
show-icon
closable
class="result-alert"
@close="testResult = null" />
<section class="settings-panel" aria-labelledby="connection-title">
<div class="section-heading">
<CloudServerOutlined />
<div><h2 id="connection-title">连接与生成参数</h2><p>这些参数用于通用对话OCR 等识别任务继续使用各自的低温配置</p></div>
</div>
<a-form layout="vertical" class="form-grid">
<a-form-item label="API 协议" class="span-full">
<a-radio-group v-model:value="form.protocol" class="protocol-grid">
<label v-for="protocol in protocols" :key="protocol.value" class="protocol-option">
<a-radio :value="protocol.value" />
<span><strong>{{ protocol.label }}</strong><small>{{ protocol.detail }}</small></span>
</label>
</a-radio-group>
</a-form-item>
<a-form-item label="API 地址" class="span-full" extra="填写服务根地址,系统会按协议自动追加请求路径。">
<a-input v-model:value="form.baseUrl" placeholder="https://api.openai.com/v1" />
</a-form-item>
<a-form-item label="模型名称">
<a-input v-model:value="form.model" placeholder="gpt-4o-mini" />
</a-form-item>
<a-form-item label="最大输出 Token" extra="单次通用回复的最大输出量,范围 64–4096。">
<a-input-number v-model:value="form.maxTokens" :min="64" :max="4096" style="width:100%" />
</a-form-item>
<a-form-item label="温度" extra="0 更稳定,数值越高回复越灵活;默认 0.7。">
<a-input-number v-model:value="form.temperature" :min="0" :max="2" :step="0.1" style="width:100%" />
</a-form-item>
</a-form>
</section>
<section class="settings-panel key-panel" aria-labelledby="key-title">
<div class="section-heading key-heading">
<KeyOutlined />
<div><h2 id="key-title">API Key</h2><p>密钥保存后只显示尾号完整值不会通过后台接口返回</p></div>
<a-tag :color="keyState.configured ? 'green' : 'default'">{{ sourceLabel(keyState.source) }}</a-tag>
</div>
<div class="key-status">
<span class="status-label">当前密钥</span>
<strong>{{ keyState.configured ? keyState.masked : '尚未配置' }}</strong>
</div>
<template v-if="keyState.canManage">
<div class="key-editor">
<a-input-password
v-model:value="apiKeyInput"
autocomplete="new-password"
placeholder="输入新的 API Key,保存后将替换当前密钥"
@press-enter="saveApiKey" />
<a-button type="primary" :loading="savingKey" @click="saveApiKey">保存密钥</a-button>
<a-popconfirm
v-if="keyState.source === 'database'"
title="删除后台保存的密钥?"
description="删除后将回退使用 LLM_API_KEY;若未配置环境变量,AI 服务会停用。"
ok-text="删除"
cancel-text="取消"
@confirm="deleteApiKey">
<a-button danger :loading="deletingKey">删除</a-button>
</a-popconfirm>
</div>
<p class="key-note">保存时由服务端自动加密不需要额外配置加密密钥</p>
</template>
<a-alert v-else type="info" message="只有超级管理员可以替换或删除 API Key。" show-icon />
</section>
</template>
</div>
</template>
<style scoped>
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
.page-heading h1 { margin: 0 0 6px; font-size: 24px; line-height: 1.3; }
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; background: #fff; }
.settings-panel + .settings-panel { margin-top: 18px; }
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 22px; }
.section-heading > :first-child { margin-top: 3px; color: #5b6bf5; font-size: 20px; }
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.span-full { grid-column: 1 / -1; }
.protocol-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; width: 100%; }
.protocol-option { display: flex; align-items: flex-start; gap: 4px; min-height: 82px; padding: 14px; border: 1px solid #e4e7eb; border-radius: 12px; cursor: pointer; transition: border-color .18s ease-out, background-color .18s ease-out; }
.protocol-option:hover { border-color: #9ba5fa; background: #f8f9ff; }
.protocol-option:has(.ant-radio-checked) { border-color: #5b6bf5; background: #f3f4ff; }
.protocol-option strong, .protocol-option small { display: block; }
.protocol-option small { margin-top: 4px; color: #5e6772; line-height: 1.45; }
.key-heading { align-items: center; }
.key-heading > div { flex: 1; }
.key-status { display: flex; align-items: baseline; gap: 18px; padding: 14px 16px; margin-bottom: 16px; border-radius: 12px; background: #f6f7f9; }
.status-label { color: #5e6772; }
.key-status strong { font-variant-numeric: tabular-nums; letter-spacing: .04em; }
.key-editor { display: grid; grid-template-columns: minmax(240px, 1fr) auto auto; gap: 10px; }
.key-note { margin: 10px 0 0; color: #5e6772; font-size: 12px; }
.result-alert { margin-bottom: 18px; }
@media (max-width: 760px) {
.page-heading { flex-direction: column; }
.form-grid, .protocol-grid, .key-editor { grid-template-columns: 1fr; }
.settings-panel { padding: 18px; }
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { BgColorsOutlined, BookOutlined } from '@ant-design/icons-vue'
import { useAdminConfigs } from '../composables/useAdminConfigs'
const keys = [
'brand.app_name', 'brand.slogan', 'brand.logo_url',
'system.default_ledger_name', 'system.max_ledgers_per_user',
]
const config = useAdminConfigs({
'brand.app_name': '记之',
'brand.slogan': '会聊天的记账本 · 让 AI 帮你管钱',
'brand.logo_url': '',
'system.default_ledger_name': '日常账本',
'system.max_ledgers_per_user': '10',
})
</script>
<template>
<div class="settings-page">
<header class="page-heading">
<div><h1>品牌与基础设置</h1><p>管理客户端品牌展示和新用户账本的默认规则</p></div>
<a-button type="primary" :loading="config.saving.value" @click="config.save(keys)">保存设置</a-button>
</header>
<a-skeleton v-if="config.loading.value" active :paragraph="{ rows: 7 }" />
<section v-else class="settings-panel">
<div class="section-heading"><BgColorsOutlined /><div><h2>品牌展示</h2><p>名称标语和 Logo 会用于客户端的公共品牌位置</p></div></div>
<a-form layout="vertical" class="form-grid">
<a-form-item label="App 名称">
<a-input :value="config.value('brand.app_name')" @input="(e:any) => config.setValue('brand.app_name', e.target.value)" />
</a-form-item>
<a-form-item label="App 标语">
<a-input :value="config.value('brand.slogan')" @input="(e:any) => config.setValue('brand.slogan', e.target.value)" />
</a-form-item>
<a-form-item label="Logo URL" class="span-full" extra="请填写客户端可直接访问的 HTTPS 图片地址。">
<a-input :value="config.value('brand.logo_url')" placeholder="https://..." @input="(e:any) => config.setValue('brand.logo_url', e.target.value)" />
</a-form-item>
</a-form>
<a-divider />
<div class="section-heading"><BookOutlined /><div><h2>新用户账本</h2><p>仅影响之后创建的账号和账本不会覆盖现有用户数据</p></div></div>
<a-form layout="vertical" class="form-grid">
<a-form-item label="默认账本名">
<a-input :value="config.value('system.default_ledger_name')" @input="(e:any) => config.setValue('system.default_ledger_name', e.target.value)" />
</a-form-item>
<a-form-item label="每人最多账本数">
<a-input-number :value="config.numberValue('system.max_ledgers_per_user')" :min="1" :max="50" style="width:100%" @change="(value: number | null) => config.setValue('system.max_ledgers_per_user', value)" />
</a-form-item>
</a-form>
</section>
</div>
</template>
<style scoped>
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
.page-heading h1 { margin: 0 0 6px; font-size: 24px; }
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; }
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; }
.section-heading > :first-child { margin-top: 3px; color: #00a67d; font-size: 20px; }
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.span-full { grid-column: 1 / -1; }
@media (max-width: 760px) { .page-heading { flex-direction: column; } .form-grid { grid-template-columns: 1fr; } .settings-panel { padding: 18px; } }
</style>
-234
View File
@@ -1,234 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { message } from 'ant-design-vue'
interface Config { id: number; key: string; value: string; version: number }
const configs = ref<Record<string, Config>>({})
const loading = ref(true)
const testing = ref(false)
const testOk = ref<boolean | null>(null)
const testMsg = ref('')
const llmKeys = ['llm.protocol','llm.base_url','llm.model','llm.max_tokens','llm.temperature']
const limitKeys = ['limit.daily_ai_messages','limit.daily_ai_messages_per_user','limit.max_monthly_budget']
const featureKeys = ['feature.ocr_enabled','feature.voice_enabled','feature.ai_auto_book','feature.sticker_enabled']
const permissionKeys = ['permission.default.ai_enabled']
const quotaDefaultKeys = ['quota.default_ai_chat_limit','quota.default_ai_chat_period']
const systemKeys = ['system.default_ledger_name','system.max_ledgers_per_user','brand.app_name','brand.slogan','brand.logo_url']
const protocols = [
{ value: 'chat_completions', label: 'Chat Completions', desc: 'OpenAI Chat Completions API。POST /v1/chat/completions', endpoint: '/chat/completions' },
{ value: 'responses', label: 'Responses', desc: 'OpenAI Responses API(新版)。POST /v1/responses', endpoint: '/responses' },
{ value: 'messages', label: 'Messages', desc: 'Anthropic Messages API。POST /v1/messages,用于 Claude 系列', endpoint: '/messages' },
]
const meta: Record<string, { label: string; desc: string; type: string }> = {
'llm.protocol': { label: 'API 协议', desc: '', type: 'protocol' },
'llm.base_url': { label: 'API 地址', desc: '', type: 'url' }, 'llm.model': { label: '模型名称', desc: '', type: 'text' },
'llm.max_tokens': { label: '最大输出 Token', desc: '单次请求上限', type: 'number' },
'llm.temperature': { label: '温度 (Temperature)', desc: '0=确定 1=创意', type: 'number' },
'limit.daily_ai_messages': { label: '全站每日 AI 消息上限', desc: '', type: 'number' },
'limit.daily_ai_messages_per_user': { label: '每人每日 AI 消息上限', desc: '', type: 'number' },
'limit.max_monthly_budget': { label: '最大月预算金额', desc: '', type: 'number' },
'feature.ocr_enabled': { label: 'OCR 小票识别', desc: '拍照记账功能', type: 'switch' },
'feature.voice_enabled': { label: '语音记账', desc: '语音输入转文字', type: 'switch' },
'feature.ai_auto_book': { label: 'AI 自动入账', desc: 'AI 识别记账意图直接入账', type: 'switch' },
'feature.sticker_enabled': { label: '表情包功能', desc: '聊天表情包面板 + AI 表情回复', type: 'switch' },
'permission.default.ai_enabled': { label: '新用户默认启用 AI', desc: '仅影响保存后注册的新用户,现有用户权限不变', type: 'switch' },
'quota.default_ai_chat_limit': { label: '新用户默认 AI 对话次数', desc: '0 表示不限次数', type: 'number' },
'quota.default_ai_chat_period': { label: '新用户默认额度周期', desc: '按上海时区自然周期重置', type: 'period' },
'system.default_ledger_name': { label: '新用户默认账本名', desc: '', type: 'text' },
'system.max_ledgers_per_user': { label: '每人最多账本数', desc: '', type: 'number' },
'brand.app_name': { label: 'App 名称', desc: '', type: 'text' },
'brand.slogan': { label: 'App 标语', desc: '', type: 'text' },
'brand.logo_url': { label: 'Logo URL', desc: '', type: 'url' },
}
onMounted(async () => {
try {
const list = await api.configs()
const map: Record<string, Config> = {}
for (const c of list) map[c.key] = c
configs.value = map
if (!map['llm.protocol']) setVal('llm.protocol', 'chat_completions')
if (!map['quota.default_ai_chat_limit']) setVal('quota.default_ai_chat_limit', 50)
if (!map['quota.default_ai_chat_period']) setVal('quota.default_ai_chat_period', 'day')
selProtocol.value = getVal('llm.protocol') || 'chat_completions'
} finally { loading.value = false }
})
function getVal(key: string): any {
const c = configs.value[key]
if (!c) return ''
const m = meta[key]
if (m?.type === 'number') return Number(c.value) || 0
if (m?.type === 'switch') return c.value === 'true'
return c.value
}
function setVal(key: string, val: any) {
const str = typeof val === 'boolean' ? String(val) : String(val)
if (configs.value[key]) configs.value[key].value = str
else configs.value[key] = { id: 0, key, value: str, version: 0 }
}
const selProtocol = ref('chat_completions')
async function saveSection(keys: string[]) {
setVal('llm.protocol', selProtocol.value)
const tasks = keys.filter(k => configs.value[k]).map(async k => {
const cfg = configs.value[k]
try {
if (cfg.id > 0) {
const r = await api.updateConfig(cfg.id, cfg.value)
if (cfg) cfg.id = r.id
} else {
const r = await api.createConfig(cfg.key, cfg.value)
if (cfg) cfg.id = r.id
}
} catch (e: any) {
message.error(k + ' 保存失败: ' + (e?.response?.data?.detail || e?.response?.data?.error || e.message))
}
})
if (tasks.length === 0) { message.warning('没有可保存的配置'); return }
await Promise.all(tasks)
message.success('已保存 ' + tasks.length + ' 项配置')
}
async function testLlm() {
testing.value = true; testOk.value = null; testMsg.value = ''
try {
const r = await api.testLlm()
testOk.value = r.ok
testMsg.value = r.detail || r.error || ''
if (r.ok) message.success('LLM 连接正常')
else message.error(r.error || '连接失败')
} catch (e: any) {
testOk.value = false
const data = e?.response?.data
testMsg.value = data?.detail || data?.error || data?.message || e.message || '未知错误'
message.error('连接失败')
} finally { testing.value = false }
}
</script>
<template>
<div v-if="loading" style="text-align:center;padding:60px"><a-spin size="large" /></div>
<template v-else>
<h2 style="margin-bottom:18px">系统设置</h2>
<a-card title="LLM 大模型配置" size="small" style="margin-bottom:14px">
<template #extra>
<a-space>
<a-button size="small" @click="testLlm" :loading="testing">测试连接</a-button>
<a-button size="small" type="primary" @click="saveSection(llmKeys)">保存</a-button>
</a-space>
</template>
<template v-if="testOk !== null">
<a-alert :type="testOk ? 'success' : 'error'" :message="testOk ? '连接成功' : '连接失败'" :description="testMsg" show-icon style="margin-bottom:14px" closable />
</template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="API 协议" :span="2">
<a-radio-group v-model:value="selProtocol" style="width:100%">
<a-row :gutter="[8,8]">
<a-col v-for="p in protocols" :key="p.value" :span="8">
<a-radio :value="p.value" style="display:block">
<span style="font-weight:600">{{ p.label }}</span>
<div style="font-size:11px;color:#999;margin-top:2px;white-space:normal">{{ p.desc }}</div>
<div style="font-size:10px;color:#bbb;margin-top:2px;font-family:monospace">{{ p.endpoint }}</div>
</a-radio>
</a-col>
</a-row>
</a-radio-group>
</a-descriptions-item>
<a-descriptions-item label="API 地址" :span="2">
<a-input :value="getVal('llm.base_url')" @change="(e:any)=>setVal('llm.base_url', e.target.value)" placeholder="https://api.openai.com/v1" />
<div style="color:#999;font-size:11px;margin-top:2px">会自动拼上协议路径</div>
</a-descriptions-item>
<a-descriptions-item label="API Key" :span="2">
<a-alert type="info" message="API Key 仅通过服务器环境变量 LLM_API_KEY 配置,后台不会读取或显示完整密钥。" show-icon />
</a-descriptions-item>
<a-descriptions-item label="模型名称" :span="2">
<a-input :value="getVal('llm.model')" @change="(e:any)=>setVal('llm.model', e.target.value)" placeholder="gpt-4o-mini" />
</a-descriptions-item>
<a-descriptions-item label="最大输出 Token" :span="1">
<a-input-number :value="getVal('llm.max_tokens')" @change="(val:any)=>setVal('llm.max_tokens', val)" style="width:100%" :min="1" />
</a-descriptions-item>
<a-descriptions-item label="温度" :span="1">
<a-input-number :value="getVal('llm.temperature')" @change="(val:any)=>setVal('llm.temperature', val)" style="width:100%" :min="0" :max="2" :step="0.1" />
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="限额配置" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(limitKeys)">保存</a-button></template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item v-for="k in limitKeys" :key="k" :label="meta[k]?.label" :span="1">
<a-input-number :value="getVal(k)" @change="(val:any)=>setVal(k, val)" style="width:100%" :min="0" />
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="功能开关" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(featureKeys)">保存</a-button></template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item v-for="k in featureKeys" :key="k" :label="meta[k]?.label" :span="1">
<a-switch :checked="getVal(k)" @change="(val:boolean)=>setVal(k, val)" />
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="注册默认权限" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(permissionKeys)">保存</a-button></template>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item v-for="k in permissionKeys" :key="k" :label="meta[k]?.label">
<a-switch :checked="getVal(k)" @change="(val:boolean)=>setVal(k, val)" />
<span style="margin-left:10px;color:#8c8c8c">{{ meta[k]?.desc }}</span>
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="新用户 AI 对话额度" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(quotaDefaultKeys)">保存</a-button></template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="默认次数">
<a-input-number
:value="getVal('quota.default_ai_chat_limit')"
@change="(val:any)=>setVal('quota.default_ai_chat_limit', val)"
style="width:100%"
:min="0"
:max="1000000" />
<div style="color:#999;font-size:11px;margin-top:3px">0 表示不限次数</div>
</a-descriptions-item>
<a-descriptions-item label="重置周期">
<a-select
:value="getVal('quota.default_ai_chat_period') || 'day'"
@change="(val:string)=>setVal('quota.default_ai_chat_period', val)"
style="width:100%">
<a-select-option value="day">每天</a-select-option>
<a-select-option value="week">每周周一开始</a-select-option>
<a-select-option value="month">每月</a-select-option>
</a-select>
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="系统 & 品牌" size="small" style="margin-bottom:14px">
<template #extra><a-button size="small" type="primary" @click="saveSection(systemKeys)">保存</a-button></template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item v-for="k in systemKeys" :key="k" :label="meta[k]?.label" :span="k==='brand.slogan'||k==='brand.logo_url'?2:1">
<a-input-number v-if="k==='system.max_ledgers_per_user'" :value="getVal(k)" @change="(val:any)=>setVal(k, val)" style="width:100%" :min="1" :max="50" />
<a-input v-else :value="getVal(k)" @change="(e:any)=>setVal(k, e.target.value)" />
</a-descriptions-item>
</a-descriptions>
</a-card>
</template>
</template>
@@ -3,8 +3,9 @@ using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json;
using MiaoJiZhang.Api.Services;
using System.Text.Json;
using MiaoJiZhang.Api.Controllers;
using MiaoJiZhang.Api.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
@@ -128,9 +129,33 @@ public sealed class ApiFixture : IAsyncLifetime
}
[Collection(ApiCollection.Name)]
public sealed class ApiIntegrationTests(ApiFixture fixture)
{
[Fact]
public sealed class ApiIntegrationTests(ApiFixture fixture)
{
[Fact]
public async Task PublicRuntimeDefaults_AreAvailableForProductionFlows()
{
using var publicClient = fixture.Factory.CreateClient();
var avatars = await publicClient.GetFromJsonAsync<JsonElement>(
"/api/public/avatars");
var personas = await publicClient.GetFromJsonAsync<JsonElement>(
"/api/public/personas");
Assert.Contains(avatars.EnumerateArray(), item =>
item.GetProperty("key").GetString() == "cat");
Assert.Contains(personas.EnumerateArray(), item =>
item.GetProperty("key").GetString() == "sassy_cat");
using var user = await fixture.RegisterAsync("runtime_defaults_user");
foreach (var type in new[] { "expense", "income" })
{
var categories = await user.GetFromJsonAsync<JsonElement>(
$"/api/categories?type={type}");
Assert.Contains(categories.EnumerateArray(), item =>
item.GetProperty("name").GetString() == "其他");
}
}
[Fact]
public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether()
{
using var owner = await fixture.RegisterAsync("owner_account");
@@ -663,8 +688,12 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
using var superAdmin = await fixture.AdminAsync();
var suffix = Guid.NewGuid().ToString("N")[..10];
var username = $"viewer_{suffix}";
const string initialPassword = "viewer-initial-password-123";
const string permanentPassword = "viewer-permanent-password-456";
const string initialPassword = "init06";
const string permanentPassword = "new006";
var shortPasswordResponse = await superAdmin.PostAsJsonAsync(
"/api/admin/security/accounts",
new { username = $"{username}_short", password = "12345", role = "viewer" });
Assert.Equal(HttpStatusCode.BadRequest, shortPasswordResponse.StatusCode);
var createdResponse = await superAdmin.PostAsJsonAsync(
"/api/admin/security/accounts",
new { username, password = initialPassword, role = "viewer" });
@@ -859,6 +888,29 @@ public sealed class RecognitionBatchActionParserTests
Assert.Equal(1, action.Confidence);
}
}
public sealed class RecognitionAmountUpdateEvidenceTests
{
private static readonly IReadOnlyDictionary<string, string?> EvidenceOwners =
new Dictionary<string, string?>
{
["evidence-a"] = "candidate-a",
["evidence-b"] = "candidate-b"
};
[Fact]
public void AmountUpdateRequiresLinkedEvidenceAndHighConfidence()
{
Assert.False(ParseController.CanApplyAmountUpdate(
"candidate-a", null, 0.99, EvidenceOwners));
Assert.False(ParseController.CanApplyAmountUpdate(
"candidate-a", "evidence-a", 0.89, EvidenceOwners));
Assert.False(ParseController.CanApplyAmountUpdate(
"candidate-a", "evidence-b", 0.99, EvidenceOwners));
Assert.True(ParseController.CanApplyAmountUpdate(
"candidate-a", "evidence-a", 0.9, EvidenceOwners));
}
}
public sealed class BudgetRecommendationValidationTests
{
@@ -0,0 +1,45 @@
using System.Security.Cryptography;
using MiaoJiZhang.Api.Services;
using Microsoft.Extensions.Configuration;
namespace MiaoJiZhang.Api.Tests;
public sealed class LlmSecretProtectorTests
{
[Fact]
public void Protect_RoundTripsWithoutEmbeddingPlaintext()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = Convert.ToBase64String(
RandomNumberGenerator.GetBytes(48)),
})
.Build();
var protector = new LlmSecretProtector(configuration);
var encrypted = protector.Protect("sk-test-secret-1234");
Assert.DoesNotContain("sk-test-secret-1234", encrypted);
Assert.Equal("sk-test-secret-1234", protector.Unprotect(encrypted));
Assert.Equal("••••1234", LlmSecretProtector.Mask("sk-test-secret-1234"));
}
[Fact]
public void Protect_RejectsMissingServerSecret()
{
var protector = new LlmSecretProtector(new ConfigurationBuilder().Build());
var exception = Assert.Throws<InvalidOperationException>(
() => protector.Protect("sk-test"));
Assert.Contains("JWT 密钥", exception.Message);
}
[Fact]
public void Defaults_UseStableGenerationParameters()
{
Assert.Equal("1024", AppConfigDefaults.Values["llm.max_tokens"]);
Assert.Equal("0.7", AppConfigDefaults.Values["llm.temperature"]);
}
}
@@ -37,7 +37,8 @@ public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
public async Task<IActionResult> Create(CreateAdminUserRequest request, CancellationToken ct)
{
var username = request.Username.Trim();
if (username.Length is < 3 or > 64 || request.Password.Length is < 12 or > 128 ||
if (username.Length is < 3 or > 64 ||
request.Password.Length is < AdminPasswordPolicy.MinLength or > AdminPasswordPolicy.MaxLength ||
!AdminRoles.All.Contains(request.Role))
return BadRequest(new ApiError("ADMIN_ACCOUNT_INVALID", "管理员账号、密码或角色无效"));
if (await db.AdminUsers.AnyAsync(item => item.Username == username, ct))
@@ -90,8 +91,8 @@ public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
ResetAdminPasswordRequest request,
CancellationToken ct)
{
if (request.Password.Length is < 12 or > 128)
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 12 到 128 位"));
if (request.Password.Length is < AdminPasswordPolicy.MinLength or > AdminPasswordPolicy.MaxLength)
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 6 到 128 位"));
var user = await db.AdminUsers.FindAsync([id], ct);
if (user is null) return NotFound();
user.PasswordHash = AdminSessionService.HashPassword(request.Password);
@@ -53,8 +53,9 @@ public sealed class AdminAuthController(
AdminChangePasswordRequest request,
CancellationToken ct)
{
if (request.NewPassword.Length < 12 || request.NewPassword.Length > 128)
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 12 到 128 位"));
if (request.NewPassword.Length < AdminPasswordPolicy.MinLength ||
request.NewPassword.Length > AdminPasswordPolicy.MaxLength)
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 6 到 128 位"));
var principal = AdminRequestContext.Principal(HttpContext)!;
var user = await db.AdminUsers.FirstAsync(item => item.Id == principal.UserId, ct);
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
@@ -8,10 +8,13 @@ using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[AdminAuth]
[Route("api/admin")]
public class AdminController(AppDbContext db) : ControllerBase
[ApiController]
[AdminAuth]
[Route("api/admin")]
public class AdminController(
AppDbContext db,
LlmSecretProtector llmSecrets,
OpenAiVisionClient llmClient) : ControllerBase
{
[HttpGet("dashboard")] public async Task<IActionResult> Dashboard() { var tu = await db.Users.CountAsync(); var ta = await db.Users.CountAsync(u => u.LastLoginAt.HasValue && u.LastLoginAt.Value >= ChinaClock.ToUtc(ChinaClock.Now.Date) && u.LastLoginAt.Value < ChinaClock.ToUtc(ChinaClock.Now.Date.AddDays(1))); var tt = await db.Transactions.IgnoreQueryFilters().CountAsync(); var ai = await db.Transactions.IgnoreQueryFilters().CountAsync(t => TransactionSourceRules.AiAssisted.Contains(t.Source)); var at = await db.Transactions.IgnoreQueryFilters().Where(t => TransactionSourceRules.AiAssisted.Contains(t.Source)).CountAsync(); var ud = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { users = new { total = tu, activeToday = ta }, transactions = new { total = tt, aiBooked = ai }, aiAccuracy = at == 0 ? 0 : Math.Round((1.0 - (double)ud / at) * 100, 1), undoRate = at == 0 ? 0 : Math.Round((double)ud / at * 100, 1), aiMessages = await db.ChatMessages.CountAsync(m => m.Role == ChatRole.Assistant) }); }
@@ -38,9 +41,11 @@ public class AdminController(AppDbContext db) : ControllerBase
return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量修改" });
cfg.Value = req.Value;
cfg.Version++;
cfg.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
cfg.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
llmClient.InvalidateConfiguration();
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
}
[HttpPost("configs")]
public async Task<IActionResult> CreateConfig([FromBody] CreateConfigRequest req)
@@ -51,9 +56,11 @@ public class AdminController(AppDbContext db) : ControllerBase
if (await db.AppConfigs.AnyAsync(c => c.Key == req.Key))
return Conflict(new { error = "key_exists", detail = "该配置 Key 已存在,请用 PUT 更新" });
var cfg = new AppConfig { Key = req.Key.Trim(), Value = req.Value ?? "", Version = 1, UpdatedAt = DateTime.UtcNow };
db.AppConfigs.Add(cfg);
await db.SaveChangesAsync();
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
db.AppConfigs.Add(cfg);
await db.SaveChangesAsync();
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
llmClient.InvalidateConfiguration();
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
}
[HttpGet("personas")] public async Task<IActionResult> ListPersonas() => Ok(await db.AiPersonas.OrderBy(p => p.Key).ToListAsync());
@@ -71,9 +78,150 @@ public class AdminController(AppDbContext db) : ControllerBase
[HttpPut("stickers/{id:long}")] public async Task<IActionResult> UpdateSticker(long id, [FromBody] UpsertStickerRequest req) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); s.Key = req.Key; s.Label = req.Label; s.GroupKey = req.GroupKey; s.TriggerTags = req.TriggerTags; s.ImageUrl = req.ImageUrl; s.IsEnabled = req.IsEnabled; await db.SaveChangesAsync(); return Ok(s); }
[HttpDelete("stickers/{id:long}")] public async Task<IActionResult> DeleteSticker(long id) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); db.Stickers.Remove(s); await db.SaveChangesAsync(); return NoContent(); }
[HttpPost("llm/test")] public async Task<IActionResult> TestLlm([FromServices] ILlmClient llm) { var (ok, error) = await llm.TestConnectionAsync(); return Ok(new { ok, error = ok ? (string?)null : error, detail = ok ? "LLM 连接正常" : error }); }
[HttpGet("llm/settings")]
public async Task<IActionResult> GetLlmSettings()
{
var values = await db.AppConfigs
.Where(config => config.Key.StartsWith("llm."))
.ToDictionaryAsync(config => config.Key, config => config.Value);
string Read(string key) => values.GetValueOrDefault(
key,
AppConfigDefaults.Values[key]);
var encrypted = values.GetValueOrDefault(LlmSecretProtector.ConfigKey);
var source = "none";
var masked = "";
if (llmSecrets.TryUnprotect(encrypted, out var databaseKey))
{
source = "database";
masked = LlmSecretProtector.Mask(databaseKey);
}
else
{
var environmentKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
if (!string.IsNullOrWhiteSpace(environmentKey))
{
source = "environment";
masked = LlmSecretProtector.Mask(environmentKey);
}
}
return Ok(new
{
protocol = Read("llm.protocol"),
baseUrl = Read("llm.base_url"),
model = Read("llm.model"),
maxTokens = int.TryParse(Read("llm.max_tokens"), out var maxTokens)
? Math.Clamp(maxTokens, 64, 4096) : 1024,
temperature = double.TryParse(
Read("llm.temperature"),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var temperature)
? Math.Clamp(temperature, 0, 2) : 0.7,
apiKey = new
{
configured = source != "none",
masked,
source,
canManage = AdminRequestContext.Principal(HttpContext)?.Role ==
AdminRoles.SuperAdmin,
},
});
}
[HttpPut("llm/settings")]
public async Task<IActionResult> UpdateLlmSettings(
[FromBody] UpdateLlmSettingsRequest request)
{
var protocol = request.Protocol.Trim().ToLowerInvariant();
if (protocol is not ("chat_completions" or "responses" or "messages"))
return BadRequest(new { error = "protocol_invalid", detail = "API 协议不受支持" });
if (!Uri.TryCreate(request.BaseUrl, UriKind.Absolute, out var baseUri) ||
baseUri.Scheme is not ("http" or "https"))
return BadRequest(new { error = "base_url_invalid", detail = "API 地址必须是有效的 HTTP 或 HTTPS 地址" });
if (string.IsNullOrWhiteSpace(request.Model))
return BadRequest(new { error = "model_required", detail = "模型名称不能为空" });
if (request.MaxTokens is < 64 or > 4096)
return BadRequest(new { error = "max_tokens_invalid", detail = "最大输出 Token 必须在 64 到 4096 之间" });
if (request.Temperature is < 0 or > 2)
return BadRequest(new { error = "temperature_invalid", detail = "温度必须在 0 到 2 之间" });
var values = new Dictionary<string, string>
{
["llm.protocol"] = protocol,
["llm.base_url"] = request.BaseUrl.Trim().TrimEnd('/'),
["llm.model"] = request.Model.Trim(),
["llm.max_tokens"] = request.MaxTokens.ToString(
System.Globalization.CultureInfo.InvariantCulture),
["llm.temperature"] = request.Temperature.ToString(
System.Globalization.CultureInfo.InvariantCulture),
};
await UpsertConfigsAsync(values);
llmClient.InvalidateConfiguration();
return await GetLlmSettings();
}
[HttpPut("llm/api-key")]
[AdminAuth(AdminRoles.SuperAdmin)]
public async Task<IActionResult> UpdateLlmApiKey([FromBody] UpdateLlmApiKeyRequest request)
{
if (string.IsNullOrWhiteSpace(request.ApiKey))
return BadRequest(new { error = "api_key_required", detail = "API Key 不能为空" });
if (request.ApiKey.Trim().Length > 8192)
return BadRequest(new { error = "api_key_too_long", detail = "API Key 长度异常" });
string protectedValue;
try { protectedValue = llmSecrets.Protect(request.ApiKey); }
catch (InvalidOperationException exception)
{
return Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "密钥加密尚未配置",
detail: exception.Message);
}
await UpsertConfigsAsync(new Dictionary<string, string>
{
[LlmSecretProtector.ConfigKey] = protectedValue,
});
llmClient.InvalidateConfiguration();
return await GetLlmSettings();
}
[HttpDelete("llm/api-key")]
[AdminAuth(AdminRoles.SuperAdmin)]
public async Task<IActionResult> DeleteLlmApiKey()
{
var config = await db.AppConfigs.FirstOrDefaultAsync(
item => item.Key == LlmSecretProtector.ConfigKey);
if (config is not null)
{
db.AppConfigs.Remove(config);
await db.SaveChangesAsync();
}
llmClient.InvalidateConfiguration();
return await GetLlmSettings();
}
[HttpPost("llm/test")]
public async Task<IActionResult> TestLlm()
{
llmClient.InvalidateConfiguration();
var (ok, error) = await llmClient.TestConnectionAsync();
return Ok(new
{
ok,
error = ok ? (string?)null : error,
detail = ok ? "LLM 连接正常" : error,
});
}
[HttpPost("configs/init")] public async Task<IActionResult> InitConfigs() { var now = DateTime.UtcNow; var defaults = new Dictionary<string, string> { ["brand.app_name"] = "记之", ["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱", ["brand.logo_url"] = "", ["llm.protocol"] = "responses", ["llm.base_url"] = "https://api.openai.com/v1", ["llm.model"] = "gpt-4o-mini", ["llm.max_tokens"] = "1024", ["llm.temperature"] = "0.8", ["limit.daily_ai_messages"] = "200", ["limit.daily_ai_messages_per_user"] = "50", ["limit.max_monthly_budget"] = "99999999", ["feature.ocr_enabled"] = "true", ["feature.voice_enabled"] = "true", ["feature.ai_auto_book"] = "true", ["feature.sticker_enabled"] = "true", ["system.default_ledger_name"] = "日常账本", ["system.max_ledgers_per_user"] = "10", ["feature.screenshot_bookkeeping_enabled"] = "true", ["permission.default.ai_enabled"] = "true", ["quota.default_ai_chat_limit"] = "50", ["quota.default_ai_chat_period"] = "day" }; var existing = await db.AppConfigs.Select(c => c.Key).ToListAsync(); var added = 0; foreach (var (key, value) in defaults) { if (!existing.Contains(key)) { db.AppConfigs.Add(new AppConfig { Key = key, Value = value, Version = 1, UpdatedAt = now }); added++; } } if (added > 0) await db.SaveChangesAsync(); return Ok(new { added, total = defaults.Count }); }
[HttpPost("configs/init")]
public async Task<IActionResult> InitConfigs()
{
var added = await AppConfigDefaults.EnsureAsync(db);
return Ok(new { added, total = AppConfigDefaults.Values.Count });
}
[HttpGet("categories")] public async Task<IActionResult> ListSystemCategories() => Ok(await db.Categories.Where(c => c.UserId == null && !c.IsDeleted).OrderBy(c => c.Type).ThenBy(c => c.SortOrder).ToListAsync());
[HttpPost("categories")]
@@ -264,17 +412,53 @@ public class AdminController(AppDbContext db) : ControllerBase
[HttpGet("users/{id:long}/stats")] public async Task<IActionResult> UserStats(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); var txCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id); var aiCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && TransactionSourceRules.AiAssisted.Contains(t.Source)); var undone = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { totalTransactions = txCount, aiBooked = aiCount, aiAccuracy = aiCount == 0 ? 0 : Math.Round((1.0 - (double)undone / aiCount) * 100, 1) }); }
private static bool IsSecret(string key) =>
key.Equals("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
private static bool IsSecret(string key) =>
key.StartsWith("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
key.Contains("password", StringComparison.OrdinalIgnoreCase) ||
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
}
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
private async Task UpsertConfigsAsync(IReadOnlyDictionary<string, string> values)
{
var keys = values.Keys.ToArray();
var existing = await db.AppConfigs
.Where(config => keys.Contains(config.Key))
.ToDictionaryAsync(config => config.Key);
var now = DateTime.UtcNow;
foreach (var (key, value) in values)
{
if (existing.TryGetValue(key, out var config))
{
config.Value = value;
config.Version++;
config.UpdatedAt = now;
}
else
{
db.AppConfigs.Add(new AppConfig
{
Key = key,
Value = value,
Version = 1,
UpdatedAt = now,
});
}
}
await db.SaveChangesAsync();
}
}
public record UpdateConfigRequest(string Value);
public record CreateConfigRequest(string Key, string Value);
public record UpsertPersonaRequest(string Key, string Name, string Description, string SampleLine, string PromptTemplate, bool IsEnabled = true);
public record UpsertAvatarRequest(string Key, string Name, string SpeechTic, string? ImageUrl, bool IsEnabled = true);
public record UpsertStickerRequest(string Key, string Label, string GroupKey, string? TriggerTags, string? ImageUrl, bool IsEnabled = true);
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
public record UpdateLlmSettingsRequest(
string Protocol,
string BaseUrl,
string Model,
int MaxTokens,
double Temperature);
public record UpdateLlmApiKeyRequest(string ApiKey);
@@ -364,6 +364,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
{
var candidateIds = manifest.Candidates.Select(item => item.CandidateId).ToHashSet();
var evidenceById = manifest.Evidence.ToDictionary(item => item.EvidenceId);
var evidenceOwners = evidenceById.ToDictionary(
entry => entry.Key,
entry => entry.Value.CandidateId);
var selected = modelActions
.Where(action => action.CandidateId != null && candidateIds.Contains(action.CandidateId))
.GroupBy(action => action.CandidateId!)
@@ -401,7 +404,19 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
transferDirection = candidate.TransferDirection;
reason = "转账方向不明确,已保留本地结果";
}
var amount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
var proposedAmount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
var amountChanged = proposedAmount != candidate.Amount;
var amountUpdateAllowed = !amountChanged ||
model?.Action == "update" && CanApplyAmountUpdate(
candidate.CandidateId,
model.EvidenceId,
model.Confidence,
evidenceOwners);
var amount = amountUpdateAllowed ? proposedAmount : candidate.Amount;
if (amountChanged && !amountUpdateAllowed)
{
reason = "金额修改证据不足,已保留本地金额";
}
var category = FindCategory(
categories,
type == "income" || transferDirection == "in"
@@ -464,6 +479,18 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
}
return result.Take(20).ToList();
}
internal static bool CanApplyAmountUpdate(
string candidateId,
string? evidenceId,
double confidence,
IReadOnlyDictionary<string, string?> evidenceOwners)
{
return confidence >= 0.9 &&
!string.IsNullOrWhiteSpace(evidenceId) &&
evidenceOwners.TryGetValue(evidenceId, out var owner) &&
owner == candidateId;
}
private async Task<bool> FeatureEnabled(string key)
{
+8 -3
View File
@@ -63,6 +63,7 @@ builder.Services.AddScoped<AiChatQuotaService>();
builder.Services.AddScoped<BudgetPushService>();
builder.Services.AddScoped<AdminSessionService>();
builder.Services.AddScoped<AdminBootstrapService>();
builder.Services.AddSingleton<LlmSecretProtector>();
builder.Services.AddSingleton<PushTokenProtector>();
builder.Services.AddScoped<AiPermissionFilter>();
builder.Services.AddHttpClient("LlmClient");
@@ -165,10 +166,14 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
await AppConfigDefaults.EnsureAsync(db);
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
if (app.Environment.IsDevelopment())
await DbSeeder.SeedAsync(db);
}
// These records are runtime defaults, not development fixtures. Production
// databases also need them for onboarding, category fallback and stickers.
// DbSeeder only inserts into an empty catalog, so existing admin-managed
// records are preserved.
await DbSeeder.SeedAsync(db);
}
if (app.Environment.IsDevelopment())
app.MapOpenApi();
@@ -15,10 +15,10 @@ public sealed class AdminBootstrapService(
var username = configuration["Admin:BootstrapUsername"]?.Trim();
var password = configuration["Admin:BootstrapPassword"];
if (string.IsNullOrWhiteSpace(username) || username.Length is < 3 or > 64 ||
string.IsNullOrWhiteSpace(password) || password.Length < 12)
string.IsNullOrWhiteSpace(password) || password.Length < AdminPasswordPolicy.MinLength)
{
throw new InvalidOperationException(
"首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 12 位");
"首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 6 位");
}
var now = DateTime.UtcNow;
@@ -0,0 +1,7 @@
namespace MiaoJiZhang.Api.Services;
public static class AdminPasswordPolicy
{
public const int MinLength = 6;
public const int MaxLength = 128;
}
@@ -0,0 +1,60 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public static class AppConfigDefaults
{
public static readonly IReadOnlyDictionary<string, string> Values =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["brand.app_name"] = "记之",
["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱",
["brand.logo_url"] = "",
["llm.protocol"] = "responses",
["llm.base_url"] = "https://api.openai.com/v1",
["llm.model"] = "gpt-4o-mini",
["llm.max_tokens"] = "1024",
["llm.temperature"] = "0.7",
["limit.daily_ai_messages"] = "200",
["limit.daily_ai_messages_per_user"] = "50",
["limit.max_monthly_budget"] = "99999999",
["feature.ocr_enabled"] = "true",
["feature.voice_enabled"] = "true",
["feature.ai_auto_book"] = "true",
["feature.sticker_enabled"] = "true",
["feature.screenshot_bookkeeping_enabled"] = "true",
["permission.default.ai_enabled"] = "true",
["quota.default_ai_chat_limit"] = "50",
["quota.default_ai_chat_period"] = "day",
["system.default_ledger_name"] = "日常账本",
["system.max_ledgers_per_user"] = "10",
};
public static async Task<int> EnsureAsync(
AppDbContext db,
CancellationToken ct = default)
{
var existing = await db.AppConfigs
.Select(config => config.Key)
.ToListAsync(ct);
var keys = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
var now = DateTime.UtcNow;
var added = 0;
foreach (var (key, value) in Values)
{
if (keys.Contains(key)) continue;
db.AppConfigs.Add(new AppConfig
{
Key = key,
Value = value,
Version = 1,
UpdatedAt = now,
});
added++;
}
if (added > 0) await db.SaveChangesAsync(ct);
return added;
}
}
@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Text;
namespace MiaoJiZhang.Api.Services;
public sealed class LlmSecretProtector(IConfiguration configuration)
{
public const string ConfigKey = "llm.api_key_encrypted";
private const string Prefix = "v1";
public string Protect(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("API Key 不能为空", nameof(value));
var key = ReadEncryptionKey();
var nonce = RandomNumberGenerator.GetBytes(12);
var plaintext = Encoding.UTF8.GetBytes(value.Trim());
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];
using var aes = new AesGcm(key, tag.Length);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
return string.Join(':', Prefix,
Convert.ToBase64String(nonce),
Convert.ToBase64String(ciphertext),
Convert.ToBase64String(tag));
}
public string Unprotect(string protectedValue)
{
var parts = protectedValue.Split(':');
if (parts.Length != 4 || parts[0] != Prefix)
throw new CryptographicException("不支持的密钥密文格式");
var nonce = Convert.FromBase64String(parts[1]);
var ciphertext = Convert.FromBase64String(parts[2]);
var tag = Convert.FromBase64String(parts[3]);
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(ReadEncryptionKey(), tag.Length);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return Encoding.UTF8.GetString(plaintext);
}
public bool TryUnprotect(string? protectedValue, out string value)
{
value = "";
if (string.IsNullOrWhiteSpace(protectedValue)) return false;
try
{
value = Unprotect(protectedValue);
return !string.IsNullOrWhiteSpace(value);
}
catch (Exception exception) when (
exception is ArgumentException or FormatException or
CryptographicException or InvalidOperationException)
{
return false;
}
}
public static string Mask(string value)
{
if (string.IsNullOrEmpty(value)) return "";
var suffixLength = Math.Min(4, value.Length);
return $"••••{value[^suffixLength..]}";
}
private byte[] ReadEncryptionKey()
{
var jwtSecret = configuration["Jwt:Secret"];
if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret.Length < 32)
throw new InvalidOperationException("服务端 JWT 密钥配置无效,无法保护 API Key");
return HMACSHA256.HashData(
Encoding.UTF8.GetBytes(jwtSecret),
Encoding.UTF8.GetBytes("jizhi:llm-api-key-encryption:v1"));
}
}
@@ -13,22 +13,26 @@ namespace MiaoJiZhang.Api.Services;
public partial class OpenAiVisionClient : ILlmClient
{
private readonly IServiceScopeFactory _sf;
private readonly HttpClient _http;
private readonly ILogger<OpenAiVisionClient> _logger;
private string? _baseUrl, _apiKey, _model, _protocol;
private int _maxTokens = 1024;
private readonly HttpClient _http;
private readonly ILogger<OpenAiVisionClient> _logger;
private readonly LlmSecretProtector _secretProtector;
private string? _baseUrl, _apiKey, _model, _protocol;
private int _maxTokens = 1024;
private double _temperature = 0.7;
private DateTime _last = DateTime.MinValue;
private static readonly object _lk = new();
public OpenAiVisionClient(
IServiceScopeFactory sf,
IHttpClientFactory hf,
ILogger<OpenAiVisionClient> logger)
IServiceScopeFactory sf,
IHttpClientFactory hf,
LlmSecretProtector secretProtector,
ILogger<OpenAiVisionClient> logger)
{
_sf = sf;
_http = hf.CreateClient("LlmClient");
_http.Timeout = TimeSpan.FromSeconds(120);
_logger = logger;
_http = hf.CreateClient("LlmClient");
_http.Timeout = TimeSpan.FromSeconds(120);
_secretProtector = secretProtector;
_logger = logger;
}
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
@@ -200,6 +204,7 @@ public partial class OpenAiVisionClient : ILlmClient
flowSessionId drop
evidenceId create evidenceId
update/create type expenseincome transferamount 0transfer transferDirection=in|out
update amount candidateId evidenceId confidence 0.9
沿reason 40
""";
var messages = new List<object>();
@@ -678,14 +683,22 @@ public partial class OpenAiVisionClient : ILlmClient
public async Task<(bool, string?)> TestConnectionAsync(CancellationToken ct = default)
{
Load();
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
if (_protocol != "responses")
return (false, "AI Agent 记账要求使用 Responses 协议");
try
{
var tool = new AgentToolDefinition(
Load();
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
try
{
if (_protocol != "responses")
{
var reply = await L(
"你正在执行连接测试,只回复 OK。",
"测试连接",
ct);
return string.IsNullOrWhiteSpace(reply)
? (false, "模型没有返回内容")
: (true, null);
}
var tool = new AgentToolDefinition(
"diagnostic_echo",
"连接测试时必须调用的无副作用工具",
JsonSerializer.Deserialize<JsonElement>(
@@ -715,8 +728,13 @@ public partial class OpenAiVisionClient : ILlmClient
var msgs = new List<object>();
if (_protocol == "messages") msgs.Add(new { role = "user", content = s + "\n\n" + u });
else { msgs.Add(new { role = "system", content = s }); msgs.Add(new { role = "user", content = u }); }
await SA(BuildBody(msgs, _maxTokens, 0.7), onToken, ct);
}
await SA(BuildBody(msgs, _maxTokens, _temperature), onToken, ct);
}
public void InvalidateConfiguration()
{
lock (_lk) _last = DateTime.MinValue;
}
void Load()
{
@@ -727,23 +745,36 @@ public partial class OpenAiVisionClient : ILlmClient
try
{
using var scope = _sf.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
_baseUrl = (
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
config.GetValueOrDefault("llm.base_url", "https://api.openai.com/v1") ??
"").TrimEnd('/');
_model = Environment.GetEnvironmentVariable("LLM_MODEL") ??
config.GetValueOrDefault("llm.model", "gpt-4o-mini");
_protocol = Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
config.GetValueOrDefault("llm.protocol", "chat_completions");
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
_apiKey = _secretProtector.TryUnprotect(
config.GetValueOrDefault(LlmSecretProtector.ConfigKey),
out var protectedApiKey)
? protectedApiKey
: Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
_baseUrl = (
config.GetValueOrDefault("llm.base_url") ??
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
"https://api.openai.com/v1").TrimEnd('/');
_model = config.GetValueOrDefault("llm.model") ??
Environment.GetEnvironmentVariable("LLM_MODEL") ??
"gpt-4o-mini";
_protocol = config.GetValueOrDefault("llm.protocol") ??
Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
"responses";
_maxTokens = int.TryParse(
config.GetValueOrDefault("llm.max_tokens"),
out var maxTokens)
? Math.Clamp(maxTokens, 64, 4096)
: 1024;
_last = DateTime.UtcNow;
? Math.Clamp(maxTokens, 64, 4096)
: 1024;
_temperature = double.TryParse(
config.GetValueOrDefault("llm.temperature"),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var temperature)
? Math.Clamp(temperature, 0, 2)
: 0.7;
_last = DateTime.UtcNow;
}
catch (Exception exception)
{
@@ -753,7 +784,7 @@ public partial class OpenAiVisionClient : ILlmClient
}
async Task<string?> L(string sys, string user, CancellationToken ct)
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, 0.7), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, _temperature), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
object BuildBody(List<object> msgs, int maxT, double temp) => _protocol switch
{ "messages" => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp }, "responses" => new { model = _model, input = msgs, max_output_tokens = maxT, temperature = temp, thinking = new { type = "disabled" } }, _ => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp } };
@@ -99,7 +99,7 @@ public static class DbSeeder
new AppConfig { Key = "llm.model", Value = "gpt-4o-mini", Version = 1, UpdatedAt = now },
new AppConfig { Key = "llm.max_tokens", Value = "1024", Version = 1, UpdatedAt = now },
new AppConfig { Key = "llm.temperature", Value = "0.8", Version = 1, UpdatedAt = now },
new AppConfig { Key = "llm.temperature", Value = "0.7", Version = 1, UpdatedAt = now },
new AppConfig { Key = "limit.daily_ai_messages", Value = "200", Version = 1, UpdatedAt = now },
new AppConfig { Key = "limit.daily_ai_messages_per_user", Value = "50", Version = 1, UpdatedAt = now },
new AppConfig { Key = "limit.max_monthly_budget", Value = "99999999", Version = 1, UpdatedAt = now },
+3 -1
View File
@@ -1,6 +1,8 @@
# 喵记账 · AI 记账 APP 设计文档
> 状态:UI/UX 设计阶段 · 更新于 2026-07-18
> 状态:设计规范已落地,持续跟随实现迭代 · 更新于 2026-08-21
实现进度、已完成模块和当前风险见 [`STATUS.md`](STATUS.md)。本文保留产品决策、视觉规范和后台边界。
## 1. 产品概述
+17 -14
View File
@@ -8,11 +8,11 @@
### 后端版本号
- 文件:`backend/MiaoJiZhang.Api/Program.cs`
- 变量`apiVersion = "20260718-1600"`(修改此行即可
- 配置`Build:Version`(未注入时默认为 `dev`
- 显示位置:
- `GET /api/ping` 返回 JSON `{ "version": "...", "built": "..." }`
- `GET /api/version` 同上
- 更新方法:修改 `apiVersion` 字符串,重启后端
- 更新方法:通过环境变量或部署配置注入 `Build__Version`,重启后端
### Flutter App 版本号
- 文件:`frontend/lib/shared/version.dart`
@@ -22,9 +22,9 @@
- 登录页底部
- 「我的」页底部
- 构建命令:
```bash
```powershell
flutter clean
flutter build apk --release --dart-define=APP_VERSION=20260718-1600
flutter build apk --release --dart-define=APP_VERSION=20260821-135
```
⚠️ **必须加 `--dart-define`,否则 App 显示 `vdev`**
@@ -33,8 +33,7 @@
- 更新方法:重新构建并完整替换后端静态资源目录。
```bash
cd admin-web && npm run build
find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete
cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
Copy-Item -Recurse -Force dist\* ..\backend\MiaoJiZhang.Api\wwwroot\
```
---
@@ -45,7 +44,7 @@
```bash
cd backend
export Admin__BootstrapUsername='admin'
export Admin__BootstrapPassword='replace-with-a-random-password-of-at-least-12-characters'
export Admin__BootstrapPassword='replace-with-a-password-longer-than-5-characters'
dotnet build
# 重启
powershell -Command "Get-Process dotnet | Stop-Process -Force"
@@ -57,12 +56,15 @@ dotnet run --project MiaoJiZhang.Api
引导变量。正式环境必须使用 HTTPS 并保持 `Admin__CookieSecure=true`。本地纯 HTTP 调试时才可
临时设置 `Admin__CookieSecure=false`。
后台“AI 配置 → 模型服务”可以直接保存和替换 LLM API Key。实际 API Key 使用 AES-GCM
加密后写入配置表,加密密钥由服务端从必填的 `Jwt__Secret` 自动派生,无需增加部署变量;
页面和接口只显示 API Key 尾号。旧的 `LLM_API_KEY` 仍作为回退配置,后台保存的密钥优先。
### 2. Admin Web
```bash
```powershell
cd admin-web
npm run build
find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete
cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
Copy-Item -Recurse -Force dist\* ..\backend\MiaoJiZhang.Api\wwwroot\
```
浏览器打开 `http://localhost:5000/` 或 `http://{电脑IP}:5000/`。如界面未更新请 **Ctrl+Shift+R** 强制刷新。
@@ -71,7 +73,7 @@ cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
cd frontend
flutter clean
flutter pub get
flutter build apk --release --dart-define=APP_VERSION=20260718-1600
flutter build apk --release --dart-define=APP_VERSION=20260821-135
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
@@ -111,10 +113,10 @@ jizhang/
│ └── features/ # 业务页面
├── admin-web/ # Vue3 + Ant Design 后台
│ └── src/
│ ├── App.vue # ★ 侧边栏版本号
│ ├── App.vue # Admin 壳与导航
│ └── views/ # 管理页面
├── docs/ # 项目说明、设计规范、开发说明
└── design/ # UI 稿、UX 原型、配色探索
├── design/ # UI 稿、UX 原型、配色探索
└── docs/ # 项目状态、设计、开发与客户端对接文档
```
---
@@ -136,5 +138,6 @@ jizhang/
| 修改了代码但 App 不变 | 没有 clean 构建,或没加 dart-define | `flutter clean && flutter build apk --release --dart-define=APP_VERSION=...` |
| 修改了代码但后台不变 | 后端还在跑旧进程 | 先 `Stop-Process -Name dotnet -Force` 再 `dotnet run` |
| Admin Web 界面不变 | 浏览器缓存 | Ctrl+Shift+R 强制刷新,或用无痕模式打开 |
| 数据库结构不一致 | 未执行最新 EF Migration | `dotnet ef database update` 后再启动服务 |
| Windows 文件路径错误 | 中文路径编码 | 用 python3 读文件时加 `encoding='utf-8'` |
| sed 破坏代码 | git-bash 的 sed 不兼容 | 禁止用 sed 改 dart/vue 源码,用 Write/Edit 工具 |
+13 -8
View File
@@ -1,6 +1,8 @@
# 喵记账 · 项目骨架
> Flutter 客户端 + .NET 9 后端 · 搭建于 2026-07-18
> Flutter 客户端 + .NET 9 后端 · 当前状态更新于 2026-08-21
当前实现状态与待办以 [`STATUS.md`](STATUS.md) 为准;本文保留项目入口、运行方式和接口索引。
## 目录结构
@@ -50,7 +52,7 @@ jizhang/
2.`backend/MiaoJiZhang.Api/appsettings.json` 里的连接串(`your_password`
3.`Jwt:Secret` 为 ≥32 字符的随机串
4. `cd backend && dotnet run --project MiaoJiZhang.Api`
- 开发期 `Program.cs``EnsureCreated` 建表 + 跑种子数据
- 数据库结构通过 `MiaoJiZhang.Infrastructure/Persistence/Migrations` 维护;本地首次启动前执行 `dotnet ef database update`
- OpenAPI 文档:`http://localhost:5000/openapi/v1.json`
5. 测试注册:
```bash
@@ -93,15 +95,18 @@ flutter run
9. ~~性格设置页~~ ✅(PUT /api/users/me/companion + CompanionPage,形象/性格/三滑杆)
10. ~~分类管理~~ ✅(POST/DELETE /api/categories + CategoryManagePage,自定义分类增删)
11. ~~语音/OCR 流程~~ ✅(POST /api/parse + ParseSheet 确认入账;ASR/OCR SDK 接入后自动填充文本)
12. ~~LLM 接入层~~ ✅(ILlmClient 抽象 + NullLlmClient 回退规则版;对接真实 LLM 时实现该接口即可
12. ~~LLM 接入层~~ ✅(已有客户端抽象、权限/额度和回退路径;仍需真实环境联调
13. ~~Admin Web 后台~~ ✅(配置、分类、AI 形象/性格、表情包、用户、推送、审计与管理员账号页面已存在)
14. ~~EF Core Migration~~ ✅(已有多次迁移;发布前仍需执行全新库和升级库验证)
## 遗留(需要外部资源/发布前处理)
- ASR(语音转文字)与拍照 OCR 的 SDK 选型接入(ParseSheet 已留好入口,接入后把识别文本传入即可)
- 真实 LLM 对接(实现 ILlmClient;性格 Prompt 已在 AiPersona 表可后台调)
- SVG 图标(category_icon.dart / stickerStyle 集中管理,直接替换映射即可)
- Admin Web 后台(`docs/DESIGN.md` 第 8 节;实体 AppConfig/AiPersona/AiAvatar/Sticker 已建好)
- EF Migration 替代 EnsureCreatedJwt Secret 换生产值;baseUrl 环境化
- 真实 LLM、OCR/ASR、截图识别和推送供应商的生产配置与联调
- JWT Secret、管理员引导变量、数据库连接串、API Base URL 和更新下载地址环境化
## 已完成的接口清单
@@ -129,6 +134,6 @@ flutter run
## 技术债 / 注意
- 后端用 `EnsureCreated` 跳过 Migration,便于快速起盘;正式环境前要切到 `dotnet ef migrations add Init`
- `appsettings.json` 的 `Jwt:Secret` 是占位值,**生产必须换**
- 前端 `main_shell.dart` 的 5 个 branch 暂用 `Placeholder()`,下一步逐个填真实页面
- `Build:Version` 未注入时后端显示 `dev`;发布构建必须显式注入版本。
- `appsettings.json` 的 JWT、数据库和外部服务配置不能直接用于生产。
- Admin Web 的 `wwwroot/assets` 是构建生成物;本地构建后不要把临时 hashed 文件混入提交。
+71
View File
@@ -0,0 +1,71 @@
# 记之 · 当前项目状态
> 更新时间:2026-08-21
## 总览
项目已经从 UI/接口骨架进入「客户端、后台和服务端能力联调」阶段。当前主干包含 Flutter 客户端、.NET 9 API、Vue 3 管理后台、数据库迁移和自动化测试;`main``origin/main` 当前指向同一提交 `8828851`
## 已完成模块
### 后端 API
- 用户注册、登录、JWT、用户资料、引导和双模式切换。
- 账本、系统/自定义分类、手动记账、月账单、统计、搜索和软删除回收站。
- AI 聊天、意图识别、AI 直接入账、来源追溯、预算、月报、表情包和 AI 伙伴设置。
- 文本解析入口、截图识别相关接口、识别幂等和 AI 权限/额度控制。
- 管理后台 API:品牌配置、系统分类、AI 形象/性格、表情包、用户、推送、管理员账号、审计和密码变更。
- 账户注销清理、数据导出/恢复、同步冲突和访客数据合并相关服务。
- EF Core/MySQL 迁移已存在,覆盖初始模型、账户注销、分类颜色、AI 权限、额度、推送基础设施和转账/后台安全等变更。
### Flutter 客户端
- 启动、登录/注册、引导选模式和 AI 伙伴。
- 普通模式首页、账本切换、手动记账、账单详情/编辑、聊天和全 AI 模式。
- 统计、AI 月报、预算、搜索、分类管理、伙伴设置、回收站、数据管理和推送设置。
- 语音/OCR 确认页、截图识别设置/批量导入、识别诊断、离线提示、同步冲突和本地数据导出。
- 客户端更新检查、推送注册和本地数据库/会话恢复。
当前 `frontend/pubspec.yaml` 版本为 `1.2.5+135`;发布时仍需使用正式构建参数和发布说明。
### Admin Web
已具备登录、强制改密、仪表盘、系统设置、品牌配置、系统分类、AI 性格、AI 形象、表情包、用户、推送、管理员账号和审计页面。管理员角色权限由路由和后端共同控制。
### 测试
- 后端:API 集成测试、推送集成测试、推送供应商测试。
- Flutter:模型、主题/时间、本地导出、本地转移数据库、更新流程、视觉契约和 Widget 测试。
## 当前待办
### P0:联调与发布阻塞项
- 在真实环境验证 LLM、OCR/ASR、截图识别和推送供应商配置。
- 验证最新 EF Migration 在全新库、升级库和回滚/重试场景下的行为。
- 将数据库连接串、JWT Secret、管理员引导变量、API Base URL 和更新下载地址全部改为部署环境注入。
- 完成 Android 内部构建的截图权限、后台识别、证书和网络安全回归。
### P1:质量与体验
- 对核心流程做端到端回归:注册 → 引导 → 记账 → AI 解析 → 撤销 → 月报/预算。
- 完善错误提示、离线待同步、同步冲突和外部服务失败时的降级行为。
- 检查 Admin Web 发布资源生成策略,避免把本地 hashed 静态文件和构建目录带入工作区。
### P2:后续演进
- 完成正式 SVG/插画资源和更多运营内容配置。
- 补充后台数据看板、脱敏意图失败样本和内容版本回滚。
- 完成各平台正式签名、应用商店元数据和更新渠道配置。
## 工作区清理记录
2026-08-21 已清理未跟踪的完整备份、历史部署、`.codex-tools` 缓存、Codex 临时构建/补丁目录,以及后端 `wwwroot/assets` 的未跟踪生成资源。源码、`docs/`、设计原型、APK、Android 新代码和测试文件未删除。
## 建议验证顺序
1. 启动 MySQL,执行 `dotnet ef database update`
2. 配置后端外部服务和管理员引导变量,运行 API 集成测试。
3. 构建 Admin Web,验证登录、权限、配置和发布资源。
4. 运行 Flutter 测试,再构建 `1.2.5+135` 内部包。
5. 用真实设备验证截图识别、推送、离线同步和更新检查。
@@ -4,8 +4,10 @@
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application
android:label="@string/app_name"
@@ -50,55 +52,80 @@
android:taskAffinity=""
android:theme="@style/ProjectionConsentTheme"/>
<service
android:name=".OneShotProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection"
android:process=":recognition"
android:stopWithTask="false"/>
<service
android:name=".OneShotProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection"
android:process=":projection"
android:stopWithTask="false"/>
<service
android:name=".ScreenshotTileService"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/screenshot_tile_label"
android:process=":recognition"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<service
android:name=".ScreenshotTileService"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/screenshot_tile_label"
android:process=":recognition"
android:stopWithTask="false"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/>
</intent-filter>
</service>
<service
android:name=".PaymentNotificationListenerService"
android:exported="true"
android:label="@string/notification_listener_label"
android:process=":recognition"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<service
android:name=".PaymentNotificationListenerService"
android:exported="true"
android:label="@string/notification_listener_label"
android:process=":recognition"
android:stopWithTask="false"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService"/>
</intent-filter>
</service>
<service
android:name=".ScreenshotAccessibilityService"
android:exported="true"
android:process=":recognition"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<service
android:name=".ScreenshotAccessibilityService"
android:exported="true"
android:process=":recognition"
android:stopWithTask="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService"/>
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config"/>
</service>
<provider
android:name=".RecognitionBridgeProvider"
android:authorities="${applicationId}.recognition.bridge"
android:exported="false"
android:grantUriPermissions="false"
android:process=":recognition"/>
</service>
<service
android:name=".RecognitionKeepAliveService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":recognition"
android:stopWithTask="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps user-enabled payment accessibility and notification recognition active"/>
</service>
<receiver
android:name=".RecognitionKeepAliveReceiver"
android:enabled="true"
android:exported="false"
android:process=":recognition">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
</intent-filter>
</receiver>
<provider
android:name=".RecognitionBridgeProvider"
android:authorities="${applicationId}.recognition.bridge"
android:exported="false"
android:grantUriPermissions="false"
android:process=":recognition"/>
<meta-data
android:name="flutterEmbedding"
@@ -101,7 +101,7 @@ object LocalPaymentOcr {
flowSessionId: String,
trustedFlow: Boolean,
capturedAt: Long,
expectedAmountCents: Long?,
expectedAmountEvidence: ExpectedAmountEvidence?,
expectedType: String?,
resultTransitionObserved: Boolean,
submittedFlow: Boolean,
@@ -120,7 +120,7 @@ object LocalPaymentOcr {
flowSessionId = flowSessionId,
trustedFlow = trustedFlow,
capturedAt = capturedAt,
expectedAmountCents = expectedAmountCents,
expectedAmountEvidence = expectedAmountEvidence,
expectedType = expectedType,
resultTransitionObserved = resultTransitionObserved,
submittedFlow = submittedFlow,
@@ -199,7 +199,7 @@ object LocalPaymentOcr {
flowSessionId: String,
trustedFlow: Boolean,
capturedAt: Long,
expectedAmountCents: Long?,
expectedAmountEvidence: ExpectedAmountEvidence?,
expectedType: String?,
resultTransitionObserved: Boolean,
submittedFlow: Boolean,
@@ -322,6 +322,7 @@ object LocalPaymentOcr {
),
)
val distinctCents = ranked.map { (it.amount * 100).roundToLong() }.distinct()
val expectedAmountCents = expectedAmountEvidence?.amountCents
val expectedCandidate = expectedAmountCents?.let { expected ->
ranked.firstOrNull { (it.amount * 100).roundToLong() == expected }
}
@@ -348,15 +349,21 @@ object LocalPaymentOcr {
val selectedCents = resultSelectedCents ?: expectedAmountCents!!
val amountSource = if (resultSelectedCents == null) "expected" else "result"
val expectedMatched = expectedAmountCents?.let { it == selectedCents }
val expectedMatched = if (expectedAmountCents != null && resultSelectedCents != null) {
expectedAmountCents == resultSelectedCents
} else {
null
}
val amountConflict = expectedMatched == false
val fresh = System.currentTimeMillis() - capturedAt <= MAX_SCREENSHOT_AGE_MS
val resultAmountSafe = distinctCents.size == 1 &&
(expectedAmountCents == null || expectedMatched == true)
val highConfidence = when {
amountSource == "expected" -> trustedFlow && fresh && submittedFlow &&
resultTransitionObserved && expectedType == direction
amountSource == "expected" -> expectedAmountEvidence?.isStrong == true &&
trustedFlow && fresh && submittedFlow && resultTransitionObserved &&
expectedType == direction
status.strength == PaymentStatusStrength.STRONG -> trustedFlow && fresh &&
submittedFlow && resultTransitionObserved && resultAmountSafe
submittedFlow && resultTransitionObserved && resultAmountSafe && !amountConflict
status.strength == PaymentStatusStrength.WEAK -> OcrEvidenceEvaluator.qualifiesWeakAuto(
trustedFlow = trustedFlow && submittedFlow,
freshScreenshot = fresh,
@@ -399,8 +406,27 @@ object LocalPaymentOcr {
if (it == "income") "in" else "out"
},
counterparty = merchant.takeIf { kind == "transfer" },
amountEvidenceStrength = if (amountSource == "result" ||
expectedAmountEvidence?.isStrong == true
) {
"strong"
} else {
"weak"
},
amountCandidateCount = distinctCents.size,
expectedAmountMatched = expectedMatched,
amountIssueReason = when {
amountConflict -> "result_amount_conflict"
amountSource == "expected" && expectedAmountEvidence?.isStrong != true ->
"weak_expected_fallback"
amountSource == "expected" -> "expected_amount_fallback"
else -> null
},
)
val reason = when {
amountConflict -> "result_amount_conflict"
amountSource == "expected" && expectedAmountEvidence?.isStrong != true ->
"weak_expected_fallback"
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
highConfidence && status.strength == PaymentStatusStrength.WEAK ->
"combined_high_confidence"
@@ -1,7 +1,9 @@
package com.nx.miaoji
import android.Manifest
import android.app.StatusBarManager
import android.Manifest
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.app.StatusBarManager
import android.app.UiModeManager
import android.content.BroadcastReceiver
import android.content.ComponentName
@@ -44,11 +46,13 @@ class MainActivity : FlutterActivity() {
const val ACTION_RECOGNITION_UNDO = "recognition_undo"
const val ACTION_RECOGNITION_EDIT = "recognition_edit"
const val ACTION_RECOGNITION_BATCH_REVIEW = "recognition_batch_review"
const val ACTION_OPEN_RECOGNITION_SETTINGS = "open_recognition_settings"
const val EXTRA_SCREENSHOT_PATH = "screenshotPath"
const val EXTRA_SCREENSHOT_ERROR = "screenshotError"
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
const val EXTRA_TRANSACTION_ID = "transactionId"
}
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
const val EXTRA_TRANSACTION_ID = "transactionId"
private const val TASK_CLEANER_WINDOW_MS = 15_000L
}
private val handler by lazy { Handler(mainLooper) }
private var channel: MethodChannel? = null
@@ -68,7 +72,12 @@ class MainActivity : FlutterActivity() {
private var pendingSpeechResult: MethodChannel.Result? = null
private var speechRecognizer: SpeechRecognizer? = null
private var streamingSpeech = false
private var streamingSpeech = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
updateRecentsProtection()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -120,8 +129,8 @@ class MainActivity : FlutterActivity() {
"getRecognitionStatus" -> {
result.success(recognitionStatus())
}
"setRecognitionToggle" -> {
val response = RecognitionBridge.call(
"setRecognitionToggle" -> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_SET_TOGGLE,
extras = Bundle().apply {
@@ -138,7 +147,7 @@ class MainActivity : FlutterActivity() {
)
result.success(response?.getBoolean("success") == true)
}
"configureRecognitionContext" -> {
"configureRecognitionContext" -> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_SET_RUNTIME,
@@ -181,7 +190,16 @@ class MainActivity : FlutterActivity() {
putString("candidateId", call.argument<String>("candidateId"))
},
)
result.success(response?.getBoolean("success") == true)
val changed = response?.getBoolean("success") == true
if (changed) {
updateRecentsProtection(
response.getBoolean("keepAliveExpected"),
)
}
result.success(changed)
}
"ensureRecognitionKeepAlive" -> {
result.success(ensureRecognitionKeepAlive())
}
"openAccessibilitySettings" -> {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
@@ -223,17 +241,26 @@ class MainActivity : FlutterActivity() {
super.onResume()
updateInstallBridge?.onResume()
vendorPushBridge?.onResume()
scheduleShortcutIfNeeded()
dispatchPendingScreenshot()
dispatchPendingRecognitionAction()
scheduleShortcutIfNeeded()
dispatchPendingScreenshot()
dispatchPendingRecognitionAction()
ensureRecognitionKeepAlive()
updateRecentsProtection()
}
private fun handleIncomingIntent(incoming: Intent?) {
when (incoming?.getStringExtra(EXTRA_ACTION)) {
ACTION_SCREENSHOT_SHORTCUT -> {
shortcutQueued = true
scheduleShortcutIfNeeded()
}
ACTION_SCREENSHOT_SHORTCUT -> {
shortcutQueued = true
scheduleShortcutIfNeeded()
}
ACTION_OPEN_RECOGNITION_SETTINGS -> {
pendingRecognitionAction = mapOf(
"action" to ACTION_OPEN_RECOGNITION_SETTINGS,
)
dispatchPendingRecognitionAction()
incoming.removeExtra(EXTRA_ACTION)
}
ACTION_SCREENSHOT_RESULT -> {
val path = incoming.getStringExtra(EXTRA_SCREENSHOT_PATH)
val sessionId =
@@ -778,8 +805,8 @@ class MainActivity : FlutterActivity() {
},
)
}
private fun recognitionStatus(): Map<String, Any?> {
val response = RecognitionBridge.call(
private fun recognitionStatus(): Map<String, Any?> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_STATUS,
)
@@ -787,13 +814,58 @@ class MainActivity : FlutterActivity() {
contentResolver,
"enabled_notification_listeners",
).orEmpty()
val notificationAuthorized = enabledListeners
val notificationAuthorized = enabledListeners
.split(':')
.mapNotNull(ComponentName::unflattenFromString)
.any { it == ComponentName(this, PaymentNotificationListenerService::class.java) }
return mapOf(
"accessibilityAuthorized" to isAccessibilityEnabledInSystem(),
"accessibilityConnected" to (response?.getBoolean("accessibilityConnected") == true),
.any { it == ComponentName(this, PaymentNotificationListenerService::class.java) }
val accessibilityAuthorized = isAccessibilityEnabledInSystem()
val accessibilityConnected = response?.getBoolean("accessibilityConnected") == true
val recognitionProcessStartedAt = response?.getLong("recognitionProcessStartedAt") ?: 0L
val keepAliveExpected = response?.getBoolean("keepAliveExpected") == true
val exits = recognitionExitHistory()
val recognitionExits = exits.filter {
it.processName == "$packageName:recognition"
}
val exit = (recognitionExits.ifEmpty { exits }).maxByOrNull { it.timestamp }
val lastConnectedAt = response?.getLong("accessibilityLastConnectedAt") ?: 0L
val taskRemovedAt = RecognitionConnectionStore.lastTaskRemovedAt(this)
val cleanerExit = exits
.filter { isTaskCleanerExit(it, taskRemovedAt) }
.maxByOrNull { it.timestamp }
val taskCleanerRecoveryNeeded = accessibilityAuthorized &&
!accessibilityConnected &&
cleanerExit != null &&
cleanerExit.timestamp > lastConnectedAt
val recentsProtectionExpected = isOriginOsDevice() && keepAliveExpected
if (RecognitionConnectionStore.recentsProtectionActive(this) !=
recentsProtectionExpected
) {
updateRecentsProtection(keepAliveExpected)
}
val accessibilityConnectionState = when {
!accessibilityAuthorized -> "unauthorized"
accessibilityConnected -> "connected"
taskCleanerRecoveryNeeded -> "disconnected"
keepAliveExpected && recognitionProcessStartedAt > 0L &&
System.currentTimeMillis() - recognitionProcessStartedAt < 2_000L -> "reconnecting"
else -> "disconnected"
}
return mapOf(
"accessibilityAuthorized" to accessibilityAuthorized,
"accessibilityConnected" to accessibilityConnected,
"accessibilityConnectionState" to accessibilityConnectionState,
"accessibilityLastConnectedAt" to lastConnectedAt,
"accessibilityLastDisconnectedAt" to (response?.getLong("accessibilityLastDisconnectedAt") ?: 0L),
"recognitionProcessStartedAt" to recognitionProcessStartedAt,
"keepAliveExpected" to keepAliveExpected,
"keepAliveRunning" to (response?.getBoolean("keepAliveRunning") == true),
"keepAliveError" to response?.getString("keepAliveError"),
"recentsProtectionExpected" to recentsProtectionExpected,
"recentsProtectionActive" to RecognitionConnectionStore
.recentsProtectionActive(this),
"lastRecognitionExitAt" to (exit?.timestamp ?: 0L),
"lastRecognitionExitReason" to exit?.let(::recognitionExitReason),
"taskCleanerRecoveryNeeded" to taskCleanerRecoveryNeeded,
"notificationAuthorized" to notificationAuthorized,
"notificationConnected" to (response?.getBoolean("notificationConnected") == true),
"postNotificationsGranted" to (
@@ -813,7 +885,77 @@ class MainActivity : FlutterActivity() {
"latestStatus" to response?.getString("latestStatus"),
"latestDiagnostic" to response?.getString("latestDiagnostic"),
)
}
}
private fun ensureRecognitionKeepAlive(): Boolean {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_ENSURE_KEEP_ALIVE,
)
return response?.getBoolean("success") == true
}
private fun updateRecentsProtection(expectedOverride: Boolean? = null) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return
val recognitionExpected = expectedOverride ?: (
RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_STATUS,
)?.getBoolean("keepAliveExpected") == true
)
val shouldProtect = isOriginOsDevice() && recognitionExpected
val activityManager = getSystemService(ActivityManager::class.java)
val currentTask = activityManager.appTasks.firstOrNull {
runCatching { it.taskInfo.taskId == taskId }.getOrDefault(false)
} ?: activityManager.appTasks.firstOrNull()
val applied = runCatching {
currentTask?.setExcludeFromRecents(shouldProtect)
currentTask != null
}.getOrDefault(false)
RecognitionConnectionStore.setRecentsProtectionActive(
this,
shouldProtect && applied,
)
}
private fun isOriginOsDevice(): Boolean {
val vendor = "${Build.MANUFACTURER} ${Build.BRAND}".lowercase()
return "vivo" in vendor || "iqoo" in vendor
}
private fun recognitionExitHistory(): List<ApplicationExitInfo> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return emptyList()
return runCatching {
getSystemService(ActivityManager::class.java)
.getHistoricalProcessExitReasons(packageName, 0, 16)
.filter { info ->
info.processName == packageName ||
info.processName == "$packageName:recognition"
}
}.getOrDefault(emptyList())
}
private fun isTaskCleanerExit(info: ApplicationExitInfo, taskRemovedAt: Long): Boolean {
return info.description.orEmpty().contains("single-cleaner", ignoreCase = true) ||
(info.reason == ApplicationExitInfo.REASON_LOW_MEMORY &&
taskRemovedAt > 0L &&
kotlin.math.abs(info.timestamp - taskRemovedAt) <= TASK_CLEANER_WINDOW_MS)
}
private fun recognitionExitReason(info: ApplicationExitInfo): String {
val reason = when (info.reason) {
ApplicationExitInfo.REASON_LOW_MEMORY -> "low_memory"
ApplicationExitInfo.REASON_USER_REQUESTED -> "user_requested"
ApplicationExitInfo.REASON_CRASH -> "crash"
ApplicationExitInfo.REASON_CRASH_NATIVE -> "native_crash"
ApplicationExitInfo.REASON_ANR -> "anr"
ApplicationExitInfo.REASON_SIGNALED -> "signaled"
ApplicationExitInfo.REASON_OTHER -> "other"
else -> "reason_${info.reason}"
}
val description = info.description.orEmpty()
return if (description.isBlank()) reason else "$reason: $description"
}
private fun acknowledgeRecognition(call: MethodCall, result: MethodChannel.Result) {
val response = RecognitionBridge.call(
@@ -8,6 +8,7 @@ class PaymentNotificationListenerService : NotificationListenerService() {
override fun onListenerConnected() {
super.onListenerConnected()
isConnected = true
RecognitionKeepAliveService.ensureRunning(this)
Log.i(TAG, "Notification recognition listener connected")
}
@@ -25,6 +25,36 @@ data class PaymentSignal(
val identityConfidence: String = "strong",
val transferDirection: String? = null,
val counterparty: String? = null,
val amountEvidenceStrength: String = "strong",
val amountCandidateCount: Int = 1,
val expectedAmountMatched: Boolean? = null,
val amountIssueReason: String? = null,
)
data class ExpectedAmountEvidence(
val amountCents: Long,
val flowSessionId: String,
val windowId: Int,
val pageFingerprint: String,
val capturedAtEpochMs: Long,
val candidateCount: Int,
val source: String,
val strength: String,
) {
val isStrong: Boolean get() = strength == "strong"
fun belongsTo(flowId: String, flowStartedAt: Long, committedAt: Long?): Boolean =
flowSessionId == flowId &&
capturedAtEpochMs >= flowStartedAt &&
(committedAt == null || capturedAtEpochMs <= committedAt)
}
data class PaymentParseOutcome(
val signal: PaymentSignal?,
val amountCandidateCount: Int,
val resultAmountCents: Long?,
val expectedAmountMatched: Boolean?,
val reason: String?,
)
enum class PaymentStatusStrength(val wireValue: String) {
@@ -44,7 +74,7 @@ object PaymentParser {
val supportedPackages = setOf(WECHAT, ALIPAY)
private val amountPatterns = listOf(
Regex("""(?:实付|付款金额|支付金额|收款金额|到账金额|交易金额|金额)[:\s]*[¥¥]?\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
Regex("""(?:实付|付款金额|支付金额|转账金额|红包金额|收款金额|到账金额|交易金额|金额)[:\s]*[¥¥]?\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
Regex("""[¥¥]\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
Regex("""([0-9]+(?:\.[0-9]{1,2})?)\s*元"""),
)
@@ -95,7 +125,8 @@ object PaymentParser {
)
private val paymentInputWords = listOf(
"输入支付密码", "请输入支付密码", "确认转账", "确认支付", "确认付款",
"立即支付", "立即付款", "继续付款",
"立即支付", "立即付款", "继续付款", "转账全额", "添加转账说明",
"请输入转账金额", "输入转账金额",
)
private val redPacketSendContextWords = listOf(
"发红包", "塞钱进红包", "红包金额", "发送红包", "普通红包", "拼手气红包",
@@ -128,17 +159,47 @@ object PaymentParser {
windowId: Int,
flowSessionId: String? = null,
trustedFlow: Boolean = false,
expectedAmountCents: Long? = null,
expectedAmountEvidence: ExpectedAmountEvidence? = null,
expectedType: String? = null,
resultTransitionObserved: Boolean = false,
submittedFlow: Boolean = false,
flowKind: String? = null,
): PaymentSignal? {
): PaymentSignal? = fromAccessibilityOutcome(
packageName = packageName,
text = text,
eventTime = eventTime,
windowId = windowId,
flowSessionId = flowSessionId,
trustedFlow = trustedFlow,
expectedAmountEvidence = expectedAmountEvidence,
expectedType = expectedType,
resultTransitionObserved = resultTransitionObserved,
submittedFlow = submittedFlow,
flowKind = flowKind,
).signal
fun fromAccessibilityOutcome(
packageName: String,
text: String,
eventTime: Long,
windowId: Int,
flowSessionId: String? = null,
trustedFlow: Boolean = false,
expectedAmountEvidence: ExpectedAmountEvidence? = null,
expectedType: String? = null,
resultTransitionObserved: Boolean = false,
submittedFlow: Boolean = false,
flowKind: String? = null,
): PaymentParseOutcome {
val expectedEvidence = expectedAmountEvidence
val expectedCents = expectedEvidence?.amountCents
val resultCandidates = amountCandidateCents(text)
val uniqueResultAmount = resultCandidates.singleOrNull()
if (packageName !in supportedPackages ||
containsBlockedStatus(text) ||
isHistoryPageText(text) ||
isPaymentInputPage(text)
) return null
) return PaymentParseOutcome(null, resultCandidates.size, null, null, null)
val eventId = flowSessionId?.let { "a:" + packageName + ":" + it }
?: "a:" + windowId + ":" + sha256(normalize(text)) + ":" + (eventTime / 10_000L)
val status = detectStatus(text, expectedType)
@@ -156,46 +217,85 @@ object PaymentParser {
null
}
if (parsed != null) {
val uniqueResultAmount = uniqueAmountCents(text)
val standaloneRedPacketIncome = parsed.recognitionKind in setOf(
"red_packet_receive",
"red_packet_refund",
)
val expectedMatches = expectedAmountCents == null ||
uniqueResultAmount == expectedAmountCents
val expectedMatches = expectedCents == null ||
uniqueResultAmount == expectedCents
val conflict = expectedCents != null && uniqueResultAmount != null &&
uniqueResultAmount != expectedCents
val evidenceHigh = when {
standaloneRedPacketIncome -> uniqueResultAmount == parsed.amountCents
submittedFlow -> trustedFlow && resultTransitionObserved &&
uniqueResultAmount == parsed.amountCents && expectedMatches
uniqueResultAmount == parsed.amountCents && expectedMatches && !conflict
else -> false
}
return parsed.copy(
val signal = parsed.copy(
evidenceConfidence = if (evidenceHigh) "high" else "confirm",
amountEvidenceStrength = if (uniqueResultAmount != null) "strong" else "weak",
amountCandidateCount = resultCandidates.size,
expectedAmountMatched = if (expectedCents != null && uniqueResultAmount != null) {
expectedMatches
} else {
null
},
amountIssueReason = if (conflict) "result_amount_conflict" else null,
)
return PaymentParseOutcome(
signal,
resultCandidates.size,
uniqueResultAmount,
signal.expectedAmountMatched,
signal.amountIssueReason,
)
}
val redPacketSentSurface = flowKind == "red_packet_send" &&
hasRedPacketSentSurface(text)
if (status.strength == PaymentStatusStrength.NONE && !redPacketSentSurface) return null
val direction = status.direction ?: if (redPacketSentSurface) "expense" else return null
val resultAmount = uniqueAmountCents(text)
if (status.strength == PaymentStatusStrength.NONE && !redPacketSentSurface) {
return PaymentParseOutcome(null, resultCandidates.size, null, null, null)
}
val direction = status.direction ?: if (redPacketSentSurface) "expense" else {
return PaymentParseOutcome(null, resultCandidates.size, null, null, "direction_unknown")
}
val resultAmount = uniqueResultAmount
val canUseExpectedAmount = resultAmount == null &&
expectedAmountCents != null &&
expectedCents != null &&
submittedFlow &&
resultTransitionObserved &&
expectedType == direction
val amountCents = resultAmount ?: expectedAmountCents?.takeIf { canUseExpectedAmount }
?: return null
val expectedMatched = expectedAmountCents != null && amountCents == expectedAmountCents
val amountCents = resultAmount ?: expectedCents?.takeIf { canUseExpectedAmount }
?: return PaymentParseOutcome(
null,
resultCandidates.size,
resultAmount,
null,
if (expectedCents == null) "expected_amount_missing" else "missing_amount",
)
val expectedMatched = if (expectedCents != null && resultAmount != null) {
resultAmount == expectedCents
} else {
null
}
val conflict = expectedMatched == false
val expectedStrong = expectedEvidence?.isStrong == true
val evidenceHigh = trustedFlow && submittedFlow && resultTransitionObserved &&
expectedMatched && expectedType == direction
expectedType == direction && !conflict &&
(resultAmount != null || expectedStrong)
val kind = flowKind ?: recognitionKind(text, direction)
val merchant = extractMerchant(text)
val orderId = extractOrderId(text)
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
if (it == "income") "in" else "out"
}
return PaymentSignal(
val issueReason = when {
conflict -> "result_amount_conflict"
resultAmount == null && !expectedStrong -> "weak_expected_fallback"
resultAmount == null -> "expected_amount_fallback"
else -> null
}
val signal = PaymentSignal(
packageName = packageName,
channel = "accessibility",
amountCents = amountCents,
@@ -222,6 +322,17 @@ object PaymentParser {
),
transferDirection = transferDirection,
counterparty = merchant.takeIf { kind == "transfer" },
amountEvidenceStrength = if (resultAmount != null || expectedStrong) "strong" else "weak",
amountCandidateCount = resultCandidates.size,
expectedAmountMatched = expectedMatched,
amountIssueReason = issueReason,
)
return PaymentParseOutcome(
signal,
resultCandidates.size,
resultAmount,
expectedMatched,
issueReason,
)
}
fun fromNotification(notification: StatusBarNotification): PaymentSignal? {
@@ -313,8 +424,12 @@ object PaymentParser {
}
fun uniqueAmountCents(value: String): Long? {
return amountCandidateCents(value).singleOrNull()
}
fun amountCandidateCents(value: String): List<Long> {
val normalized = normalize(value)
val cents = buildList {
return buildList {
amountPatterns.forEach { pattern ->
pattern.findAll(normalized).forEach { match ->
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let { amount ->
@@ -332,13 +447,63 @@ object PaymentParser {
}
}
}.distinct()
return cents.singleOrNull()
}
fun expectedAmountEvidence(
pageText: String,
eventText: String,
flowSessionId: String,
windowId: Int,
capturedAtEpochMs: Long,
): ExpectedAmountEvidence? {
val pageFingerprint = sha256(normalize(pageText))
val labeled = LABELED_AMOUNT.findAll(normalize(pageText))
.mapNotNull { match ->
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let {
(it * 100).roundToLong()
}
}
.filter { it > 0 }
.distinct()
.toList()
if (labeled.size == 1) {
return ExpectedAmountEvidence(
labeled.single(),
flowSessionId,
windowId,
pageFingerprint,
capturedAtEpochMs,
labeled.size,
"labeled_page",
"strong",
)
}
val eventCandidates = amountCandidateCents(eventText)
if (eventCandidates.size != 1) return null
val explicitEvent = EXPLICIT_AMOUNT.containsMatchIn(normalize(eventText))
return ExpectedAmountEvidence(
eventCandidates.single(),
flowSessionId,
windowId,
pageFingerprint,
capturedAtEpochMs,
eventCandidates.size,
"payment_event",
if (explicitEvent) "strong" else "weak",
)
}
fun isPaymentInputPage(value: String): Boolean {
val normalized = normalize(value)
val transferInputComposite = "转账给" in normalized && (
"转账全额" in normalized ||
"添加转账说明" in normalized ||
"请输入金额" in normalized ||
Regex("""[¥¥]\s*0(?:\.0{1,2})?""").containsMatchIn(normalized)
)
return paymentInputWords.any(normalized::contains) ||
redPacketSendActionWords.any(normalized::contains)
redPacketSendActionWords.any(normalized::contains) ||
transferInputComposite
}
fun detectFlowKind(value: String): String? {
@@ -408,12 +573,17 @@ object PaymentParser {
val normalized = normalize(value)
amountPatterns.firstNotNullOfOrNull { pattern ->
pattern.find(normalized)?.groupValues?.getOrNull(1)?.toDoubleOrNull()
}?.let { return it }
}?.takeIf { it.isFinite() && it > 0.0 }?.let { return it }
val match = STANDALONE_AMOUNT.matchEntire(normalized) ?: return null
val number = match.groupValues[2]
val hasCurrencyOrUnit = match.groupValues[1].isNotEmpty() ||
match.groupValues[3].isNotEmpty()
return if (!hasCurrencyOrUnit && !number.contains('.')) null else number.toDoubleOrNull()
val amount = if (!hasCurrencyOrUnit && !number.contains('.')) {
null
} else {
number.toDoubleOrNull()
}
return amount?.takeIf { it.isFinite() && it > 0.0 }
}
fun extractOrderId(value: String): String? =
@@ -548,6 +718,10 @@ object PaymentParser {
private val STANDALONE_AMOUNT = Regex(
"""^\s*([¥¥]?)\s*([0-9]{1,8}(?:\.[0-9]{1,2})?)\s*(元?)\s*$""",
)
private val LABELED_AMOUNT = Regex(
"""(?:实付|付款金额|支付金额|转账金额|红包金额|收款金额|到账金额|交易金额)[: \t]*(?:\r?\n[: \t]*)?[¥¥]?[ \t]*([0-9]+(?:\.[0-9]{1,2})?)""",
)
private val EXPLICIT_AMOUNT = Regex("""[¥¥]\s*[0-9]+(?:\.[0-9]{1,2})?|[0-9]+\.[0-9]{1,2}""")
private const val MAX_SUCCESS_HEADING_CHARS = 48
private const val MAX_PAYMENT_SCAN_LINES = 48
private const val MAX_HISTORY_TITLE_LINES = 3
@@ -13,6 +13,9 @@ class RecognitionBridgeProvider : ContentProvider() {
private val captureResults = ConcurrentHashMap<String, CaptureResult>()
override fun onCreate(): Boolean {
context?.let {
RecognitionConnectionStore.markRecognitionProcessStarted(it, PROCESS_STARTED_AT)
}
context?.let(RecognitionCoordinator::get)
return true
}
@@ -22,20 +25,40 @@ class RecognitionBridgeProvider : ContentProvider() {
return when (method) {
METHOD_STATUS -> Bundle().apply {
putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected)
putLong(
"accessibilityLastConnectedAt",
RecognitionConnectionStore.lastConnectedAt(appContext),
)
putLong(
"accessibilityLastDisconnectedAt",
RecognitionConnectionStore.lastDisconnectedAt(appContext),
)
putLong("recognitionProcessStartedAt", PROCESS_STARTED_AT)
putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected)
putBoolean(
"keepAliveExpected",
RecognitionKeepAliveService.isExpected(appContext),
)
putBoolean("keepAliveRunning", RecognitionKeepAliveService.isRunning)
putString("keepAliveError", RecognitionKeepAliveService.lastStartError)
putString("settings", RecognitionSettings.statusJson(appContext))
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
putString("latestDiagnostic", RecognitionDiagnostics.latest(appContext))
}
METHOD_SET_TOGGLE -> Bundle().apply {
putBoolean(
"success",
RecognitionSettings.setToggle(
appContext,
extras?.getString("key").orEmpty(),
extras?.getBoolean("enabled") ?: false,
),
METHOD_SET_TOGGLE -> {
val changed = RecognitionSettings.setToggle(
appContext,
extras?.getString("key").orEmpty(),
extras?.getBoolean("enabled") ?: false,
)
if (changed) RecognitionKeepAliveService.reconcile(appContext)
Bundle().apply {
putBoolean("success", changed)
putBoolean(
"keepAliveExpected",
RecognitionKeepAliveService.isExpected(appContext),
)
}
}
METHOD_CLEAR_DIAGNOSTIC -> Bundle().apply {
RecognitionDiagnostics.clear(appContext)
@@ -51,6 +74,9 @@ class RecognitionBridgeProvider : ContentProvider() {
)
putBoolean("success", true)
}
METHOD_ENSURE_KEEP_ALIVE -> Bundle().apply {
putBoolean("success", RecognitionKeepAliveService.reconcile(appContext))
}
METHOD_REQUEST_SCREENSHOT -> requestScreenshot(extras)
METHOD_SCREENSHOT_RESULT -> takeScreenshotResult(arg)
METHOD_DRAIN -> Bundle().apply {
@@ -143,9 +169,11 @@ class RecognitionBridgeProvider : ContentProvider() {
private data class CaptureResult(val path: String?, val error: String?)
companion object {
private val PROCESS_STARTED_AT = System.currentTimeMillis()
const val METHOD_STATUS = "status"
const val METHOD_SET_TOGGLE = "setToggle"
const val METHOD_SET_RUNTIME = "setRuntime"
const val METHOD_SET_RUNTIME = "setRuntime"
const val METHOD_ENSURE_KEEP_ALIVE = "ensureKeepAlive"
const val METHOD_CLEAR_DIAGNOSTIC = "clearDiagnostic"
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
const val METHOD_SCREENSHOT_RESULT = "screenshotResult"
@@ -0,0 +1,70 @@
package com.nx.miaoji
import android.content.Context
object RecognitionConnectionStore {
private const val PREFS = "recognition_connection"
private const val KEY_LAST_CONNECTED_AT = "accessibility_last_connected_at"
private const val KEY_LAST_DISCONNECTED_AT = "accessibility_last_disconnected_at"
private const val KEY_LAST_TASK_REMOVED_AT = "recognition_last_task_removed_at"
private const val KEY_PROCESS_STARTED_AT = "recognition_process_started_at"
private const val KEY_RECENTS_PROTECTION_ACTIVE = "recents_protection_active"
fun markConnected(context: Context, at: Long = System.currentTimeMillis()) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_LAST_CONNECTED_AT, at)
.apply()
}
fun markDisconnected(context: Context, at: Long = System.currentTimeMillis()) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_LAST_DISCONNECTED_AT, at)
.apply()
}
fun lastConnectedAt(context: Context): Long =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getLong(KEY_LAST_CONNECTED_AT, 0L)
fun lastDisconnectedAt(context: Context): Long =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getLong(KEY_LAST_DISCONNECTED_AT, 0L)
fun markTaskRemoved(context: Context, at: Long = System.currentTimeMillis()) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_LAST_TASK_REMOVED_AT, at)
.apply()
}
fun lastTaskRemovedAt(context: Context): Long =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getLong(KEY_LAST_TASK_REMOVED_AT, 0L)
fun markRecognitionProcessStarted(
context: Context,
at: Long = System.currentTimeMillis(),
) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_PROCESS_STARTED_AT, at)
.apply()
}
fun recognitionProcessStartedAt(context: Context): Long =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getLong(KEY_PROCESS_STARTED_AT, 0L)
fun setRecentsProtectionActive(context: Context, active: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_RECENTS_PROTECTION_ACTIVE, active)
.apply()
}
fun recentsProtectionActive(context: Context): Boolean =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_RECENTS_PROTECTION_ACTIVE, false)
}
@@ -0,0 +1,16 @@
package com.nx.miaoji
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class RecognitionKeepAliveReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action !in setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
) return
RecognitionKeepAliveService.ensureRunning(context)
}
}
@@ -0,0 +1,169 @@
package com.nx.miaoji
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.content.ContextCompat
class RecognitionKeepAliveService : Service() {
override fun onCreate() {
super.onCreate()
isRunning = true
lastStartError = null
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isExpected(this)) {
stopSelf()
return START_NOT_STICKY
}
promoteToForeground()
return START_STICKY
}
override fun onDestroy() {
isRunning = false
super.onDestroy()
}
override fun onTaskRemoved(rootIntent: Intent?) {
RecognitionConnectionStore.markTaskRemoved(this)
super.onTaskRemoved(rootIntent)
}
override fun onBind(intent: Intent?): IBinder? = null
private fun promoteToForeground() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"智能识别后台保护",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "保持用户主动开启的微信、支付宝智能识别在后台运行"
setSound(null, null)
enableVibration(false)
setShowBadge(false)
},
)
}
private fun buildNotification(): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(MainActivity.EXTRA_ACTION, MainActivity.ACTION_OPEN_RECOGNITION_SETTINGS)
}
val pendingIntent = PendingIntent.getActivity(
this,
NOTIFICATION_ID,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
@Suppress("DEPRECATION")
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
Notification.Builder(this)
}
return builder
.setSmallIcon(android.R.drawable.ic_menu_view)
.setContentTitle("智能识别运行中")
.setContentText("正在等待微信、支付宝支付结果")
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SERVICE)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.build()
}
companion object {
private const val CHANNEL_ID = "recognition_keep_alive"
private const val NOTIFICATION_ID = 2201
@Volatile
var isRunning = false
private set
@Volatile
var lastStartError: String? = null
private set
fun isExpected(context: Context): Boolean {
val settings = RecognitionSettings.snapshot(context)
return settings.accessibilityEvents ||
settings.notificationEvents ||
settings.aiScreenshot
}
fun ensureRunning(context: Context): Boolean {
val appContext = context.applicationContext
if (!isExpected(appContext)) {
stop(appContext)
return true
}
if (!canShowNotification(appContext)) {
lastStartError = "请允许通知权限后重新启动后台保护"
return false
}
return try {
ContextCompat.startForegroundService(
appContext,
Intent(appContext, RecognitionKeepAliveService::class.java),
)
lastStartError = null
true
} catch (error: RuntimeException) {
lastStartError = error.message ?: "系统限制了后台保护启动"
false
}
}
fun reconcile(context: Context): Boolean =
if (isExpected(context)) ensureRunning(context) else {
stop(context)
true
}
fun stop(context: Context) {
context.applicationContext.stopService(
Intent(context.applicationContext, RecognitionKeepAliveService::class.java),
)
isRunning = false
lastStartError = null
}
private fun canShowNotification(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
}
@@ -37,6 +37,13 @@ data class PendingRecognitionBatch(
val images: List<PendingBatchImage>,
)
internal data class AmountMergeResult(
val amountCents: Long,
val source: String,
val strength: String,
val conflict: Boolean,
)
class RecognitionStore(context: Context) :
SQLiteOpenHelper(context, "recognition_queue.db", null, 3) {
override fun onCreate(db: SQLiteDatabase) {
@@ -178,20 +185,27 @@ class RecognitionStore(context: Context) :
id = existing.id
val mergedMask = existing.channelMask or channelBit
val hasNonAiEvidence = mergedMask and channelBit("recognition_ai").inv() != 0
val high = hasNonAiEvidence &&
val mergedPayload = mergePayload(existing.payload, signal)
val amountConflict = mergedPayload.optBoolean("amountConflict", false)
val high = !amountConflict && hasNonAiEvidence &&
(signalHigh ||
(mergedMask and ACCESSIBILITY_NOTIFICATION_MASK) ==
ACCESSIBILITY_NOTIFICATION_MASK ||
existing.highConfidence)
val mergedPayload = mergePayload(existing.payload, signal)
writableDatabase.update(
"candidates",
ContentValues().apply {
put("channel_mask", mergedMask)
put("known_template", if (existing.knownTemplate || signal.knownTemplate) 1 else 0)
put("amount_cents", kotlin.math.round(mergedPayload.optDouble("amount") * 100).toLong())
put("direction", mergedPayload.optString("type", signal.type))
put("high_confidence", if (high) 1 else 0)
put("payload_encrypted", encryptPayload(mergedPayload))
if (high && existing.state == "pending_confirm") put("state", "auto_ready")
if (amountConflict) {
put("state", "pending_confirm")
} else if (high && existing.state == "pending_confirm") {
put("state", "auto_ready")
}
put("updated_at", now)
put("available_at", now + MERGE_DELAY_MS)
if (batchId != null) {
@@ -525,9 +539,21 @@ class RecognitionStore(context: Context) :
val original = JSONObject(row.payload.toString())
val kind = action.optString("action")
val payload = JSONObject(row.payload.toString())
if (kind == "update") applyActionFields(payload, action)
if (kind == "update") {
applyActionFields(payload, action)
if (payload.optDouble("amount") != original.optDouble("amount")) {
payload
.put("amountSource", "ai_batch")
.put("amountEvidenceStrength", "strong")
.put("amountConflict", false)
.remove("conflictingAmount")
payload.remove("conflictingAmountSource")
payload.remove("amountIssueReason")
}
}
payload.put("sourceOverride", "recognition_ai")
val reason = action.optString("reason", "AI 对账")
val amountConflict = payload.optBoolean("amountConflict", false)
writableDatabase.update(
"candidates",
ContentValues().apply {
@@ -540,8 +566,15 @@ class RecognitionStore(context: Context) :
put("channel_mask", row.channelMask or channelBit("recognition_ai"))
put("ai_action", kind)
put("ai_reason", reason.take(80))
put("state", if (kind == "drop") "ai_dropped" else "auto_ready")
put("high_confidence", 1)
put(
"state",
when {
kind == "drop" -> "ai_dropped"
amountConflict -> "pending_confirm"
else -> "auto_ready"
},
)
put("high_confidence", if (amountConflict) 0 else 1)
put("updated_at", now)
},
"id = ? AND batch_id = ?",
@@ -631,14 +664,17 @@ class RecognitionStore(context: Context) :
// The server guarantees one action per candidate. Preserve anything omitted
// by a malformed response instead of silently losing a payment.
writableDatabase.rawQuery(
"SELECT id FROM candidates WHERE batch_id = ? AND state = 'batch_collecting'",
"SELECT id, payload_encrypted FROM candidates WHERE batch_id = ? AND state = 'batch_collecting'",
arrayOf(batchId),
).use { cursor ->
while (cursor.moveToNext()) {
val amountConflict = decryptPayload(cursor.getString(1))
?.optBoolean("amountConflict", false) == true
writableDatabase.update(
"candidates",
ContentValues().apply {
put("state", "auto_ready")
put("state", if (amountConflict) "pending_confirm" else "auto_ready")
put("high_confidence", if (amountConflict) 0 else 1)
put("ai_action", "keep")
put("ai_reason", "AI 未返回该候选,已保留本地结果")
put("updated_at", now)
@@ -1011,6 +1047,10 @@ class RecognitionStore(context: Context) :
.put("recognitionKind", signal.recognitionKind)
.put("categoryHint", signal.categoryHint)
.put("amountSource", signal.amountSource)
.put("amountEvidenceStrength", signal.amountEvidenceStrength)
.put("amountCandidateCount", signal.amountCandidateCount)
.put("expectedAmountMatched", signal.expectedAmountMatched)
.put("amountIssueReason", signal.amountIssueReason)
.put("resultFingerprint", signal.resultFingerprint)
.put("identityConfidence", signal.identityConfidence)
.put("transferDirection", signal.transferDirection)
@@ -1043,7 +1083,36 @@ class RecognitionStore(context: Context) :
if (existing.isNull("sourceText") && signal.sourceText != null) existing.put("sourceText", signal.sourceText)
if (existing.optString("recognitionKind").isBlank()) existing.put("recognitionKind", signal.recognitionKind)
if (existing.isNull("categoryHint") && signal.categoryHint != null) existing.put("categoryHint", signal.categoryHint)
if (existing.optString("amountSource").isBlank()) existing.put("amountSource", signal.amountSource)
val existingAmountCents = kotlin.math.round(existing.optDouble("amount") * 100).toLong()
val amountMerge = mergeAmountEvidence(
existingAmountCents = existingAmountCents,
existingSource = existing.optString("amountSource", "result"),
existingStrength = existing.optString("amountEvidenceStrength", "strong"),
incoming = signal,
)
val amountConflict = existing.optBoolean("amountConflict", false) ||
amountMerge.conflict
existing
.put("amount", amountMerge.amountCents / 100.0)
.put("amountSource", amountMerge.source)
.put("amountEvidenceStrength", amountMerge.strength)
.put("amountConflict", amountConflict)
.put("amountCandidateCount", maxOf(
existing.optInt("amountCandidateCount", 0),
signal.amountCandidateCount,
))
if (amountMerge.conflict) {
existing
.put("conflictingAmount", signal.amountCents / 100.0)
.put("conflictingAmountSource", signal.amountSource)
}
if (amountConflict) {
existing.put("amountIssueReason", "result_amount_conflict")
} else if (signal.amountIssueReason != null) {
existing.put("amountIssueReason", signal.amountIssueReason)
} else if (amountMerge.source == "result") {
existing.remove("amountIssueReason")
}
if (existing.isNull("resultFingerprint") && signal.resultFingerprint != null) {
existing.put("resultFingerprint", signal.resultFingerprint)
}
@@ -1111,6 +1180,81 @@ class RecognitionStore(context: Context) :
private const val MAX_BATCH_ITEMS = 10
private const val MAX_BATCH_IMAGE_BYTES = 1024 * 1024
internal fun mergeAmountEvidence(
existingAmountCents: Long,
existingSource: String,
existingStrength: String,
incoming: PaymentSignal,
): AmountMergeResult {
if (existingAmountCents == incoming.amountCents) {
val incomingRank = amountEvidenceRank(
incoming.amountSource,
incoming.amountEvidenceStrength,
)
val existingRank = amountEvidenceRank(existingSource, existingStrength)
return if (incomingRank > existingRank) {
AmountMergeResult(
incoming.amountCents,
incoming.amountSource,
incoming.amountEvidenceStrength,
false,
)
} else {
AmountMergeResult(
existingAmountCents,
existingSource,
existingStrength,
false,
)
}
}
val incomingRank = amountEvidenceRank(
incoming.amountSource,
incoming.amountEvidenceStrength,
)
val existingRank = amountEvidenceRank(existingSource, existingStrength)
val bothStrong = existingStrength == "strong" &&
incoming.amountEvidenceStrength == "strong"
val independentConflict = bothStrong &&
(existingSource == "result" || incoming.amountSource == "result")
if (independentConflict) {
return AmountMergeResult(
existingAmountCents,
existingSource,
existingStrength,
true,
)
}
val useIncoming = when {
incoming.amountSource == "result" && existingSource != "result" -> true
existingSource == "result" && incoming.amountSource != "result" -> false
else -> incomingRank > existingRank
}
return if (useIncoming) {
AmountMergeResult(
incoming.amountCents,
incoming.amountSource,
incoming.amountEvidenceStrength,
false,
)
} else {
AmountMergeResult(
existingAmountCents,
existingSource,
existingStrength,
false,
)
}
}
private fun amountEvidenceRank(source: String, strength: String): Int = when {
source == "result" && strength == "strong" -> 4
source == "result" -> 3
strength == "strong" -> 2
else -> 1
}
internal fun clientRequestIdFor(signal: PaymentSignal): String {
val basis = when {
!signal.orderId.isNullOrBlank() ->
@@ -30,6 +30,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
private var lastVisualCaptureAt = 0L
private var paymentFlow: PaymentFlow? = null
private var pendingVisualCapture: Runnable? = null
private var pendingVisualCaptureAt = 0L
private var ocrInProgress = false
private var visualOperationId: String? = null
private var visualTimeout: Runnable? = null
@@ -47,7 +48,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
var kind: String,
var originWindowId: Int = windowId,
var originPageHash: String? = null,
var expectedAmountCents: Long? = null,
var expectedAmountEvidence: ExpectedAmountEvidence? = null,
var expectedType: String? = null,
var committedAt: Long? = null,
var completedAt: Long? = null,
@@ -58,6 +59,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
var resultSurfaceExited: Boolean = false,
var retryCount: Int = 0,
var probeCount: Int = 0,
var trackingTimedOut: Boolean = false,
)
private data class CaptureRequest(
@@ -78,6 +80,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
}
activeInstance = this
isConnected = true
lastConnectedAt = System.currentTimeMillis()
RecognitionConnectionStore.markConnected(this, lastConnectedAt)
RecognitionKeepAliveService.ensureRunning(this)
Log.i(TAG, "Accessibility recognition service connected")
}
@@ -188,7 +193,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
)
return
}
val observed = PaymentParser.fromAccessibility(
val observedOutcome = PaymentParser.fromAccessibilityOutcome(
packageName = recognizedPackage,
text = combined,
eventTime = currentEvent.eventTime,
@@ -196,6 +201,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
expectedType = status.direction ?: existingFlow.expectedType,
flowKind = currentKind,
)
val observed = observedOutcome.signal
val resultChanged = observed?.resultFingerprint != null &&
existingFlow.resultFingerprint != null &&
observed.resultFingerprint != existingFlow.resultFingerprint
@@ -229,6 +235,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
nextFlow,
page.nodeCount,
"tree",
parseOutcome = observedOutcome,
)
return
}
@@ -267,13 +274,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
currentEvent.windowId,
now,
forceNew = existingFlow?.completed == true ||
existingFlow?.committedAt != null ||
existingFlow?.packageName != recognizedPackage,
kind = inferredKind,
)
else -> existingFlow?.takeIf { !it.completed }
}
if (armedFlow != null) {
updateFlowEvidence(armedFlow, combined)
updateFlowEvidence(armedFlow, combined, eventText, now)
if (clickedPaymentAction) {
armedFlow.committedAt = armedFlow.committedAt ?: now
armedFlow.expectedType = armedFlow.expectedType ?: "expense"
@@ -284,19 +292,28 @@ class ScreenshotAccessibilityService : AccessibilityService() {
}
val directFlow = paymentFlow?.takeIf { !it.completed }
val directSignal = if (combined.isBlank()) null else PaymentParser.fromAccessibility(
packageName = recognizedPackage,
text = combined,
eventTime = currentEvent.eventTime,
windowId = currentEvent.windowId,
flowSessionId = directFlow?.id,
trustedFlow = directFlow?.trusted == true,
expectedAmountCents = directFlow?.expectedAmountCents,
expectedType = directFlow?.expectedType,
resultTransitionObserved = directFlow?.resultTransitionObserved == true,
submittedFlow = directFlow?.committedAt != null,
flowKind = directFlow?.kind,
)
val directOutcome = if (combined.isBlank()) null else {
PaymentParser.fromAccessibilityOutcome(
packageName = recognizedPackage,
text = combined,
eventTime = currentEvent.eventTime,
windowId = currentEvent.windowId,
flowSessionId = directFlow?.id,
trustedFlow = directFlow?.trusted == true,
expectedAmountEvidence = directFlow?.expectedAmountEvidence?.takeIf {
it.belongsTo(
directFlow.id,
directFlow.startedAt,
directFlow.committedAt,
)
},
expectedType = directFlow?.expectedType,
resultTransitionObserved = directFlow?.resultTransitionObserved == true,
submittedFlow = directFlow?.committedAt != null,
flowKind = directFlow?.kind,
)
}
val directSignal = directOutcome?.signal
if (directSignal != null) {
val flow = directFlow ?: armPaymentFlow(
recognizedPackage,
@@ -313,6 +330,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
flow,
page.nodeCount,
"tree",
parseOutcome = directOutcome,
)
return
}
@@ -350,9 +368,12 @@ class ScreenshotAccessibilityService : AccessibilityService() {
private fun disconnect() {
if (activeInstance === this) activeInstance = null
isConnected = false
lastDisconnectedAt = System.currentTimeMillis()
RecognitionConnectionStore.markDisconnected(this, lastDisconnectedAt)
pendingRetry = null
retryTimeout = null
pendingVisualCapture = null
pendingVisualCaptureAt = 0L
visualTimeout?.let(handler::removeCallbacks)
visualTimeout = null
batchCaptureTimeout?.let(handler::removeCallbacks)
@@ -411,13 +432,35 @@ class ScreenshotAccessibilityService : AccessibilityService() {
).also { paymentFlow = it }
}
private fun updateFlowEvidence(flow: PaymentFlow, text: String) {
if (text.isBlank() || flow.completed || flow.resultTransitionObserved) return
private fun updateFlowEvidence(
flow: PaymentFlow,
text: String,
eventText: String,
now: Long,
) {
if (text.isBlank() || flow.completed || flow.committedAt != null ||
flow.resultTransitionObserved
) {
return
}
if (PaymentParser.detectStatus(text).strength != PaymentStatusStrength.NONE) return
val pageHash = PaymentParser.sha256(text)
flow.originPageHash = flow.originPageHash ?: pageHash
flow.expectedAmountCents = flow.expectedAmountCents
?: PaymentParser.uniqueAmountCents(text)
val observed = PaymentParser.expectedAmountEvidence(
pageText = text,
eventText = eventText,
flowSessionId = flow.id,
windowId = flow.windowId,
capturedAtEpochMs = now,
)
if (observed != null) {
val existing = flow.expectedAmountEvidence
if (existing == null || observed.isStrong && !existing.isStrong ||
observed.source == existing.source
) {
flow.expectedAmountEvidence = observed
}
}
flow.expectedType = flow.expectedType
?: PaymentParser.inferContextDirection(text)
PaymentParser.detectFlowKind(text)?.let { detected ->
@@ -455,9 +498,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
private fun expirePaymentFlow(now: Long) {
val flow = paymentFlow ?: return
if (now - flow.startedAt > PAYMENT_FLOW_TTL_MS) {
if (flow.committedAt != null && !flow.completed && !flow.trackingTimedOut) {
recordResultPageTimeout(flow, 0)
}
paymentFlow = null
pendingVisualCapture?.let(handler::removeCallbacks)
pendingVisualCapture = null
pendingVisualCaptureAt = 0L
}
}
@@ -468,6 +515,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
stage: String,
recordDiagnostic: Boolean = true,
evidenceImage: ByteArray? = null,
parseOutcome: PaymentParseOutcome? = null,
) {
if (flow.completed) {
evidenceImage?.fill(0)
@@ -476,6 +524,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
flow.completed = true
flow.completedAt = System.currentTimeMillis()
flow.resultFingerprint = signal.resultFingerprint
pendingVisualCapture?.let(handler::removeCallbacks)
pendingVisualCapture = null
pendingVisualCaptureAt = 0L
val coordinator = RecognitionCoordinator.get(this)
val settings = RecognitionSettings.snapshot(this)
val batchEnabled = settings.aiScreenshot && settings.aiAllowed && settings.hasAccount
@@ -503,11 +554,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
"confirm"
},
nodeCount = nodeCount,
amountCandidates = 1,
reason = if (batchEnabled) "queued_for_ai" else "success",
expectedAmountMatched = flow.expectedAmountCents?.let {
it == signal.amountCents
},
amountCandidates = parseOutcome?.amountCandidateCount
?: signal.amountCandidateCount,
reason = signal.amountIssueReason
?: parseOutcome?.reason
?: if (batchEnabled) "queued_for_ai" else "success",
expectedAmountMatched = parseOutcome?.expectedAmountMatched
?: signal.expectedAmountMatched,
resultTransitionObserved = flow.resultTransitionObserved,
recognitionKind = signal.recognitionKind,
amountSource = signal.amountSource,
@@ -524,22 +577,58 @@ class ScreenshotAccessibilityService : AccessibilityService() {
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
flow.completed ||
flow.probeCount >= MAX_VISUAL_PROBES ||
ocrInProgress
flow.trackingTimedOut ||
flow.probeCount >= MAX_VISUAL_PROBES
) return
val now = System.currentTimeMillis()
if (now - flow.startedAt >= PAYMENT_FLOW_TTL_MS) {
recordResultPageTimeout(flow, nodeCount)
return
}
val throttleWait = (
VISUAL_CAPTURE_THROTTLE_MS - (now - lastVisualCaptureAt)
).coerceAtLeast(0L)
val targetAt = now + maxOf(delayMs, throttleWait)
if (pendingVisualCapture != null &&
pendingVisualCaptureAt > 0L &&
pendingVisualCaptureAt <= targetAt
) return
pendingVisualCapture?.let(handler::removeCallbacks)
pendingVisualCapture = Runnable {
pendingVisualCapture = null
pendingVisualCaptureAt = 0L
val current = paymentFlow
if (current?.id != flow.id || current.completed ||
lastPackageName != flow.packageName
) return@Runnable
if (current?.id != flow.id || current.completed || current.trackingTimedOut) {
return@Runnable
}
val currentTime = System.currentTimeMillis()
if (currentTime - current.startedAt >= PAYMENT_FLOW_TTL_MS) {
recordResultPageTimeout(current, nodeCount)
return@Runnable
}
if (lastPackageName != current.packageName) {
scheduleVisualRecognition(
current,
reason = "waiting_for_payment_app",
nodeCount = nodeCount,
delayMs = PAYMENT_APP_RECHECK_MS,
)
return@Runnable
}
if (captureInProgress || ocrInProgress) {
scheduleVisualRecognition(
current,
reason = "recognition_busy",
nodeCount = nodeCount,
delayMs = BUSY_RECHECK_MS,
)
return@Runnable
}
captureForLocalOcr(current, reason, nodeCount)
}.also { handler.postDelayed(it, maxOf(delayMs, throttleWait)) }
}.also {
pendingVisualCaptureAt = targetAt
handler.postDelayed(it, (targetAt - now).coerceAtLeast(0L))
}
}
private fun captureForLocalOcr(flow: PaymentFlow, reason: String, nodeCount: Int) {
@@ -659,10 +748,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
val recognitionSettings = RecognitionSettings.snapshot(
this@ScreenshotAccessibilityService,
)
val visualTransitionObserved = flow.resultTransitionObserved ||
flow.committedAt?.let {
capturedAt - it >= VISUAL_STABILITY_DELAY_MS
} == true
val visualTransitionObserved = flow.resultTransitionObserved
runCatching {
LocalPaymentOcr.analyze(
context = this@ScreenshotAccessibilityService,
@@ -671,7 +757,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
flowSessionId = flow.id,
trustedFlow = flow.trusted,
capturedAt = capturedAt,
expectedAmountCents = flow.expectedAmountCents,
expectedAmountEvidence = flow.expectedAmountEvidence?.takeIf {
it.belongsTo(flow.id, flow.startedAt, flow.committedAt)
},
expectedType = flow.expectedType,
resultTransitionObserved = visualTransitionObserved,
submittedFlow = flow.committedAt != null,
@@ -773,15 +861,30 @@ class ScreenshotAccessibilityService : AccessibilityService() {
nodeCount: Int,
) {
try {
val retryable = outcome.signal == null && isRetryableOcrOutcome(outcome.reason)
val shouldWait = retryable && shouldScheduleResultProbe(
probeCount = flow.probeCount,
completed = flow.completed,
expired = System.currentTimeMillis() - flow.startedAt >= PAYMENT_FLOW_TTL_MS,
)
val diagnosticReason = if (retryable && !shouldWait) {
"result_page_timeout"
} else {
outcome.reason
}
RecognitionDiagnostics.record(
this,
flow.packageName,
stage = "ocr",
result = if (outcome.signal != null) "matched" else "rejected",
result = when {
outcome.signal != null -> "matched"
shouldWait -> "waiting"
else -> "rejected"
},
nodeCount = nodeCount,
ocrMs = outcome.latencyMs,
amountCandidates = outcome.amountCandidateCount,
reason = outcome.reason,
reason = diagnosticReason,
statusStrength = outcome.statusStrength,
expectedAmountMatched = outcome.expectedAmountMatched,
resultTransitionObserved = outcome.resultTransitionObserved,
@@ -812,17 +915,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
)
return
}
val retryable = isRetryableOcrOutcome(outcome.reason)
if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) {
if (shouldWait) {
flow.retryCount += 1
scheduleVisualRecognition(
flow,
reason = "ocr_retry",
reason = "result_page_follow_up",
nodeCount = nodeCount,
delayMs = VISUAL_RETRY_DELAY_MS,
delayMs = resultProbeDelayAfterCapture(flow.probeCount),
)
return
}
if (retryable) flow.trackingTimedOut = true
if (outcome.sawSuccess) maybeUseAiFallback(flow, bitmap)
} finally {
bitmap.recycle()
@@ -925,17 +1028,51 @@ class ScreenshotAccessibilityService : AccessibilityService() {
nodeCount = nodeCount,
reason = reason,
)
if (reason == "interval_short" && flow.retryCount < MAX_VISUAL_RETRIES) {
val retryable = reason in RESULT_TRACKING_CAPTURE_RETRY_REASONS
if (retryable && shouldScheduleResultProbe(
probeCount = flow.probeCount,
completed = flow.completed,
expired = System.currentTimeMillis() - flow.startedAt >= PAYMENT_FLOW_TTL_MS,
)
) {
flow.retryCount += 1
RecognitionDiagnostics.record(
this,
flow.packageName,
stage = "capture",
result = "waiting",
nodeCount = nodeCount,
reason = reason,
)
scheduleVisualRecognition(
flow,
reason = "capture_retry",
nodeCount = nodeCount,
delayMs = VISUAL_RETRY_DELAY_MS,
delayMs = resultProbeDelayAfterCapture(flow.probeCount),
)
} else if (retryable) {
recordResultPageTimeout(flow, nodeCount)
}
}
private fun recordResultPageTimeout(flow: PaymentFlow, nodeCount: Int) {
if (flow.completed || flow.trackingTimedOut) return
flow.trackingTimedOut = true
pendingVisualCapture?.let(handler::removeCallbacks)
pendingVisualCapture = null
pendingVisualCaptureAt = 0L
RecognitionDiagnostics.record(
this,
flow.packageName,
stage = "ocr",
result = "rejected",
nodeCount = nodeCount,
reason = "result_page_timeout",
recognitionKind = flow.kind,
resultTransitionObserved = flow.resultTransitionObserved,
)
}
private fun shouldFallbackToDisplay(errorCode: Int): Boolean =
errorCode == ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
@@ -1174,14 +1311,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
private const val MIN_RESULT_TRANSITION_DELAY_MS = 250L
private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L
private const val VISUAL_STABILITY_DELAY_MS = 700L
private const val VISUAL_RETRY_DELAY_MS = 850L
private const val VISUAL_CAPTURE_THROTTLE_MS = 2_500L
private const val PAYMENT_APP_RECHECK_MS = 1_000L
private const val BUSY_RECHECK_MS = 500L
private const val CAPTURE_CALLBACK_TIMEOUT_MS = 6_000L
private const val OCR_CALLBACK_TIMEOUT_MS = 12_000L
private const val STALE_BITMAP_RELEASE_DELAY_MS = 60_000L
private const val WINDOW_CAPTURE_FALLBACK_DELAY_MS = 450L
private const val MAX_VISUAL_RETRIES = 1
private const val MAX_VISUAL_PROBES = 4
private const val MAX_VISUAL_PROBES = 5
private const val MAX_TREE_NODES = 160
private const val MAX_CHILDREN_PER_NODE = 40
private const val MAX_TEXT_CHARS = 8_000
@@ -1198,6 +1335,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED,
AccessibilityEvent.TYPE_VIEW_CLICKED,
)
private val RESULT_PROBE_DELAYS_MS = longArrayOf(2_500L, 5_000L, 10_000L, 20_000L)
private val RESULT_TRACKING_CAPTURE_RETRY_REASONS = setOf(
"secure_window",
"invalid_window",
"interval_short",
"accessibility_unavailable",
)
@Volatile
private var activeInstance: ScreenshotAccessibilityService? = null
@@ -1213,6 +1357,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
"payment_input_page",
)
internal fun shouldScheduleResultProbe(
probeCount: Int,
completed: Boolean,
expired: Boolean,
): Boolean = !completed && !expired && probeCount < MAX_VISUAL_PROBES
internal fun resultProbeDelayAfterCapture(probeCount: Int): Long =
RESULT_PROBE_DELAYS_MS[
(probeCount - 1).coerceIn(0, RESULT_PROBE_DELAYS_MS.lastIndex)
]
internal fun completedResultStartReason(
hasObservedResult: Boolean,
resultFingerprintChanged: Boolean,
@@ -1230,6 +1385,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
var isConnected = false
private set
@Volatile
var lastConnectedAt = 0L
private set
@Volatile
var lastDisconnectedAt = 0L
private set
fun requestScreenshot(
showResult: Boolean,
delayMs: Long = 0L,
@@ -93,7 +93,7 @@ class PaymentParserTest {
windowId = 7,
flowSessionId = "flow-a",
trustedFlow = true,
expectedAmountCents = 2_000L,
expectedAmountEvidence = expectedEvidence(2_000L, "strong", "flow-a"),
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = true,
@@ -107,7 +107,7 @@ class PaymentParserTest {
windowId = 7,
flowSessionId = "flow-b",
trustedFlow = true,
expectedAmountCents = 3_000L,
expectedAmountEvidence = expectedEvidence(3_000L, "strong", "flow-b"),
expectedType = "expense",
resultTransitionObserved = true,
)
@@ -117,10 +117,46 @@ class PaymentParserTest {
@Test
fun paymentInputAndAmbiguousAmountsAreRejected() {
assertTrue(PaymentParser.isPaymentInputPage("请输入支付密码\n确认转账"))
assertTrue(
PaymentParser.isPaymentInputPage(
"转账给:张三\n转账全额\n¥0.0\n添加转账说明\n转账",
),
)
assertTrue(PaymentParser.amountCandidateCents("转账全额\n¥0.0").isEmpty())
assertNull(PaymentParser.parseAmountCandidate("¥0.0"))
assertEquals(2_000L, PaymentParser.uniqueAmountCents("付款金额 ¥20.00\n¥20.00"))
assertNull(PaymentParser.uniqueAmountCents("¥20.00\n优惠 ¥2.00"))
}
@Test
fun resultPageTrackingUsesBoundedProgressiveProbes() {
assertEquals(2_500L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(1))
assertEquals(5_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(2))
assertEquals(10_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(3))
assertEquals(20_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(4))
assertTrue(
ScreenshotAccessibilityService.shouldScheduleResultProbe(
probeCount = 4,
completed = false,
expired = false,
),
)
assertFalse(
ScreenshotAccessibilityService.shouldScheduleResultProbe(
probeCount = 5,
completed = false,
expired = false,
),
)
assertFalse(
ScreenshotAccessibilityService.shouldScheduleResultProbe(
probeCount = 1,
completed = false,
expired = true,
),
)
}
@Test
fun diagnosticPreviewMasksSensitiveValuesAndLimitsLines() {
val preview = OcrDiagnosticRedactor.redact(
@@ -149,7 +185,7 @@ class PaymentParserTest {
windowId = 8,
flowSessionId = "payment-flow",
trustedFlow = true,
expectedAmountCents = 1_880L,
expectedAmountEvidence = expectedEvidence(1_880L, "strong", "payment-flow"),
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = true,
@@ -171,7 +207,7 @@ class PaymentParserTest {
windowId = 8,
flowSessionId = "payment-flow",
trustedFlow = true,
expectedAmountCents = 1_880L,
expectedAmountEvidence = expectedEvidence(1_880L, "strong", "payment-flow"),
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = false,
@@ -190,7 +226,7 @@ class PaymentParserTest {
windowId = 9,
flowSessionId = "red-packet-flow",
trustedFlow = true,
expectedAmountCents = 2_000L,
expectedAmountEvidence = expectedEvidence(2_000L, "strong", "red-packet-flow"),
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = true,
@@ -381,6 +417,166 @@ class PaymentParserTest {
)
}
@Test
fun expectedAmountEvidenceIgnoresUnlabelledChatHistory() {
val evidence = PaymentParser.expectedAmountEvidence(
pageText = "聊天记录\n¥0.01\n你发起了一笔转账\n¥0.02\n确认转账",
eventText = "确认转账",
flowSessionId = "transfer-003",
windowId = 7,
capturedAtEpochMs = 1_000L,
)
assertNull(evidence)
}
@Test
fun labeledCurrentTransferAmountWinsOverChatHistory() {
val evidence = PaymentParser.expectedAmountEvidence(
pageText = "聊天记录\n¥0.01\n¥0.02\n转账金额\n¥0.03\n确认转账",
eventText = "确认转账",
flowSessionId = "transfer-003",
windowId = 7,
capturedAtEpochMs = 1_000L,
)
assertEquals(3L, evidence?.amountCents)
assertEquals("strong", evidence?.strength)
assertEquals("labeled_page", evidence?.source)
}
@Test
fun labeledAmountMustBeOnTheSameOrAdjacentNode() {
val evidence = PaymentParser.expectedAmountEvidence(
pageText = "转账金额\n付款说明\n¥0.01",
eventText = "确认转账",
flowSessionId = "transfer-003",
windowId = 7,
capturedAtEpochMs = 1_000L,
)
assertNull(evidence)
}
@Test
fun resultWithoutAmountUsesOnlyStrongCurrentFlowEvidence() {
val outcome = PaymentParser.fromAccessibilityOutcome(
packageName = PaymentParser.WECHAT,
text = "转账成功",
eventTime = 2_000L,
windowId = 7,
flowSessionId = "transfer-003",
trustedFlow = true,
expectedAmountEvidence = expectedEvidence(3L, "strong"),
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = true,
flowKind = "transfer",
)
assertEquals(3L, outcome.signal?.amountCents)
assertEquals("high", outcome.signal?.evidenceConfidence)
assertEquals("expected_amount_fallback", outcome.reason)
assertNull(outcome.expectedAmountMatched)
}
@Test
fun weakFallbackAndConflictingResultRequireConfirmation() {
val weak = PaymentParser.fromAccessibilityOutcome(
packageName = PaymentParser.WECHAT,
text = "转账成功",
eventTime = 2_000L,
windowId = 7,
flowSessionId = "transfer-003",
trustedFlow = true,
expectedAmountEvidence = expectedEvidence(3L, "weak"),
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = true,
flowKind = "transfer",
)
val conflict = PaymentParser.fromAccessibilityOutcome(
packageName = PaymentParser.WECHAT,
text = "转账成功\n¥0.01",
eventTime = 2_000L,
windowId = 7,
flowSessionId = "transfer-003",
trustedFlow = true,
expectedAmountEvidence = expectedEvidence(3L, "strong"),
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = true,
flowKind = "transfer",
)
assertEquals("confirm", weak.signal?.evidenceConfidence)
assertEquals("weak_expected_fallback", weak.reason)
assertEquals(1L, conflict.signal?.amountCents)
assertEquals("confirm", conflict.signal?.evidenceConfidence)
assertEquals(false, conflict.expectedAmountMatched)
assertEquals("result_amount_conflict", conflict.reason)
}
@Test
fun strongerResultReplacesWeakFallbackButStrongConflictStaysFlagged() {
val resultSignal = paymentSignal(
channel = "local_ocr",
sourceEventId = "ocr:wechat:transfer-003",
flowSessionId = "transfer-003",
).copy(
amountCents = 3L,
amountSource = "result",
amountEvidenceStrength = "strong",
)
val replacement = RecognitionStore.mergeAmountEvidence(
existingAmountCents = 1L,
existingSource = "expected",
existingStrength = "weak",
incoming = resultSignal,
)
val conflict = RecognitionStore.mergeAmountEvidence(
existingAmountCents = 1L,
existingSource = "expected",
existingStrength = "strong",
incoming = resultSignal,
)
assertEquals(3L, replacement.amountCents)
assertFalse(replacement.conflict)
assertEquals(1L, conflict.amountCents)
assertTrue(conflict.conflict)
}
@Test
fun consecutiveSmallTransfersKeepFourFlowsAndSumToSevenCents() {
val amounts = listOf(1L, 1L, 2L, 3L)
val signals = amounts.mapIndexed { index, cents ->
paymentSignal(
channel = "accessibility",
sourceEventId = "a:wechat:transfer-$index",
flowSessionId = "transfer-$index",
).copy(amountCents = cents)
}
assertEquals(7L, signals.sumOf { it.amountCents })
assertEquals(4, signals.map(RecognitionStore::flowStrongKeyFor).toSet().size)
}
private fun expectedEvidence(
amountCents: Long,
strength: String,
flowSessionId: String = "transfer-003",
) = ExpectedAmountEvidence(
amountCents = amountCents,
flowSessionId = flowSessionId,
windowId = 7,
pageFingerprint = "payment-page",
capturedAtEpochMs = 1_500L,
candidateCount = 1,
source = "labeled_page",
strength = strength,
)
private fun paymentSignal(
channel: String,
sourceEventId: String,
+30 -6
View File
@@ -49,7 +49,6 @@ final router = GoRouter(
refreshListenable: SessionStore.instance,
redirect: (_, state) {
final aiOnly =
state.matchedLocation == '/chat' ||
state.matchedLocation == '/ai-mode' ||
state.matchedLocation == '/companion';
if (aiOnly &&
@@ -86,7 +85,15 @@ final router = GoRouter(
path: '/categories',
builder: (_, __) => const CategoryManagePage(),
),
GoRoute(path: '/report', builder: (_, __) => const ReportPage()),
GoRoute(
path: '/report',
builder: (_, state) => ReportPage(
initialPeriod: state.uri.queryParameters['period'],
initialAnchor: DateTime.tryParse(
state.uri.queryParameters['date'] ?? '',
),
),
),
GoRoute(
path: '/legal/:kind',
builder: (_, state) => LegalDocumentPage(
@@ -175,20 +182,33 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
if (SessionStore.instance.hasSession) {
await _runSafely(CurrentLedgerStore.instance.loadCached);
}
await _runSafely(RecognitionImportService.configureNativeContext);
await _runSafely(RecognitionImportService.importAutomatic);
await _runSafely(_restoreRecognitionServices);
await _runSafely(PushService.instance.initialize);
if (mounted) setState(() {});
unawaited(_refreshRemoteState());
}
Future<void> _resumeServices() async {
await _runSafely(RecognitionImportService.configureNativeContext);
await _runSafely(RecognitionImportService.importAutomatic);
unawaited(ApiClient.instance.probe());
await _runSafely(_restoreRecognitionServices);
await _runSafely(PushService.instance.refresh);
unawaited(_refreshRemoteState());
}
Future<void> _restoreRecognitionServices() async {
await RecognitionImportService.configureNativeContext();
await ScreenshotChannel.ensureRecognitionKeepAlive();
await RecognitionImportService.importAutomatic();
unawaited(_runSafely(_importAfterAccessibilityReconnect));
}
Future<void> _importAfterAccessibilityReconnect() async {
final status = await ScreenshotChannel.waitForAccessibilityConnection();
if (status.accessibilityConnected) {
await RecognitionImportService.importAutomatic();
}
}
Future<void> _refreshRemoteState() async {
await Future.wait([
_runSafely(PublicConfigApi.init),
@@ -219,6 +239,10 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
await Future<void>.delayed(const Duration(milliseconds: 180));
final context = _rootNavigatorKey.currentContext;
if (!mounted || context == null || !context.mounted) return;
if (action['action'] == 'open_recognition_settings') {
router.push('/screenshot-settings');
return;
}
await RecognitionImportService.handleAction(context, action);
}
+58 -30
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
@@ -7,6 +9,7 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class AddPage extends StatefulWidget {
@@ -36,6 +39,7 @@ class _AddPageState extends State<AddPage> {
DateTime _occurredAt = ShanghaiTime.now;
bool _saving = false;
bool _loadingCategories = true;
int _loadRevision = 0;
String get _categoryType => _tab == 'transfer'
? _transferDirection == 'in'
? 'income'
@@ -60,7 +64,7 @@ class _AddPageState extends State<AddPage> {
@override
void initState() {
super.initState();
_loadCategories();
unawaited(_loadCategories());
}
@override
@@ -71,38 +75,60 @@ class _AddPageState extends State<AddPage> {
}
Future<void> _loadCategories() async {
setState(() => _loadingCategories = true);
try {
final results = await Future.wait([
TxApi.categories('expense'),
TxApi.categories('income'),
]);
if (!mounted) return;
final revision = ++_loadRevision;
final expense = TxApi.categoriesLocal('expense');
final income = TxApi.categoriesLocal('income');
if (mounted) {
setState(() {
_categoriesByType['expense'] = results[0];
_categoriesByType['income'] = results[1];
if (_selectedByType['expense'] == null && results[0].isNotEmpty) {
_selectedByType['expense'] = results[0].first;
}
if (_selectedByType['income'] == null && results[1].isNotEmpty) {
_selectedByType['income'] = results[1].first;
}
_selectedByType['transfer_out'] ??= results[0].isEmpty
? null
: results[0].first;
_selectedByType['transfer_in'] ??= results[1].isEmpty
? null
: results[1].first;
_applyCategories(expense, income);
_loadingCategories = false;
});
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
} finally {
if (mounted) setState(() => _loadingCategories = false);
}
await Future.wait([
TxApi.categoriesRemote('expense').then<void>((values) {
if (!mounted || revision != _loadRevision) return;
setState(() {
_applyCategories(values, _categoriesByType['income'] ?? const []);
});
}, onError: (_) {}),
TxApi.categoriesRemote('income').then<void>((values) {
if (!mounted || revision != _loadRevision) return;
setState(() {
_applyCategories(_categoriesByType['expense'] ?? const [], values);
});
}, onError: (_) {}),
]);
}
void _applyCategories(List<CategoryItem> expense, List<CategoryItem> income) {
_categoriesByType['expense'] = expense;
_categoriesByType['income'] = income;
_selectedByType['expense'] = _preserveSelection(
_selectedByType['expense'],
expense,
);
_selectedByType['income'] = _preserveSelection(
_selectedByType['income'],
income,
);
_selectedByType['transfer_out'] = _preserveSelection(
_selectedByType['transfer_out'],
expense,
);
_selectedByType['transfer_in'] = _preserveSelection(
_selectedByType['transfer_in'],
income,
);
}
CategoryItem? _preserveSelection(
CategoryItem? selected,
List<CategoryItem> values,
) {
if (values.isEmpty) return null;
if (selected == null) return values.first;
return values.where((item) => item.id == selected.id).firstOrNull ??
values.first;
}
void _switchTab(String tab) {
@@ -162,6 +188,7 @@ class _AddPageState extends State<AddPage> {
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
paymentMethod: _paymentMethod,
occurredAt: _occurredAt,
localFirst: true,
);
TransactionEvents.notifyChanged();
if (mounted) context.pop(amount);
@@ -239,6 +266,7 @@ class _AddPageState extends State<AddPage> {
),
onPressed: () => context.pop(),
),
actions: [BackendStatusIcon(onRetry: _loadCategories)],
),
body: SafeArea(
child: LayoutBuilder(
+54 -34
View File
@@ -743,7 +743,8 @@ class _ChatPageState extends State<ChatPage> {
}
Widget _inputBar(BuildContext context) {
final sendEnabled = _ctrl.text.trim().isNotEmpty && !_sending;
final hasText = _ctrl.text.trim().isNotEmpty;
final sendEnabled = hasText && !_sending;
final accent = widget.aiMode ? AppTheme.ai : AppTheme.primary;
return Container(
color: context.jz.card,
@@ -866,23 +867,59 @@ class _ChatPageState extends State<ChatPage> {
),
SizedBox(width: 6),
],
SizedBox(
width: 50,
height: 38,
child: FilledButton(
onPressed: sendEnabled ? _send : null,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor: accent,
disabledBackgroundColor: context.jz.line,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(11),
AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.9, end: 1).animate(animation),
child: child,
),
),
child: Text(
'发送',
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700),
),
child: hasText
? SizedBox(
key: const ValueKey('send-message'),
width: 50,
height: 38,
child: FilledButton(
onPressed: sendEnabled ? _send : null,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor: accent,
disabledBackgroundColor: context.jz.line,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(11),
),
),
child: Text(
'发送',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
),
),
),
)
: PublicConfigApi.imageEnabled
? SizedBox(
key: const ValueKey('add-attachment'),
width: 42,
height: 42,
child: IconButton(
tooltip: '添加附件',
onPressed: _sending ? null : _openAttachment,
icon: AppIcons.icon(
AppIcons.plus,
size: 22,
color: _sending ? context.jz.text3 : context.jz.text2,
),
padding: EdgeInsets.zero,
),
)
: const SizedBox.shrink(key: ValueKey('no-composer-action')),
),
),
],
@@ -948,24 +985,6 @@ class _ChatPageState extends State<ChatPage> {
),
),
),
if (PublicConfigApi.imageEnabled)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
avatar: AppIcons.icon(
AppIcons.camera,
size: 16,
color: AppTheme.ai,
),
label: Text(
'附件',
style: TextStyle(fontSize: 11, color: context.jz.text2),
),
backgroundColor: context.jz.background,
side: BorderSide.none,
onPressed: _openAttachment,
),
),
],
),
),
@@ -1280,6 +1299,7 @@ class _ChatTabPageState extends State<ChatTabPage> {
final status = switch (accessState) {
AiAccessState.guest => '登录后可用',
AiAccessState.reauthenticate => '需要重新登录',
AiAccessState.aiDisabled => 'AI 功能已关闭',
AiAccessState.cloudDisabled => '云连接已关闭',
null => '在线',
};
+46 -18
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
@@ -10,6 +12,7 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
import 'package:miaoji_zhang/features/home/pages/tx_detail_page.dart';
import 'package:miaoji_zhang/features/home/pages/ledger_sheet.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
@@ -28,6 +31,7 @@ class HomePageState extends State<HomePage> {
bool _loading = true;
String? _error;
late DateTime _month;
int _loadRevision = 0;
@override
void initState() {
@@ -36,7 +40,7 @@ class HomePageState extends State<HomePage> {
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
TransactionEvents.revision.addListener(_refreshTransactions);
CurrentLedgerStore.instance.addListener(_refreshLedger);
refresh();
unawaited(refresh());
}
void _refreshCompanion() {
@@ -60,33 +64,56 @@ class HomePageState extends State<HomePage> {
}
Future<void> refresh() async {
final revision = ++_loadRevision;
setState(() {
_loading = true;
_error = null;
});
try {
await CurrentLedgerStore.instance.ensureLoaded();
final results = await Future.wait<dynamic>([
TxApi.month(_month.year, _month.month),
BudgetApi.get(_month.year, _month.month),
]);
if (mounted) {
await CurrentLedgerStore.instance.loadCached();
final localSummary = TxApi.monthLocal(_month.year, _month.month);
final localBudgets = BudgetApi.getLocal(_month.year, _month.month);
if (mounted && revision == _loadRevision) {
setState(() {
_summary = results[0] as MonthSummary;
_budgets = results[1] as BudgetsData;
_summary = localSummary;
_budgets = localBudgets;
});
}
} catch (error) {
try {
final summary = await TxApi.month(_month.year, _month.month);
if (mounted) setState(() => _summary = summary);
} catch (fallbackError) {
if (mounted) {
setState(() => _error = apiErrorMessage(fallbackError));
}
if (mounted && revision == _loadRevision) {
setState(() {
_error = apiErrorMessage(error);
_loading = false;
});
}
} finally {
if (mounted) setState(() => _loading = false);
return;
}
await Future.wait([
() async {
try {
await CurrentLedgerStore.instance.refreshRemote();
} catch (_) {}
}(),
() async {
try {
final summary = await TxApi.monthRemote(_month.year, _month.month);
if (mounted && revision == _loadRevision) {
setState(() => _summary = summary);
}
} catch (_) {}
}(),
() async {
try {
final budgets = await BudgetApi.getRemote(_month.year, _month.month);
if (mounted && revision == _loadRevision) {
setState(() => _budgets = budgets);
}
} catch (_) {}
}(),
]);
if (mounted && revision == _loadRevision) {
setState(() => _loading = false);
}
}
@@ -175,6 +202,7 @@ class HomePageState extends State<HomePage> {
),
),
Spacer(),
BackendStatusIcon(onRetry: refresh),
IconButton(
icon: AppIcons.icon(
AppIcons.search,
@@ -25,14 +25,6 @@ class _MainShellState extends State<MainShell> {
return AnimatedBuilder(
animation: SessionStore.instance,
builder: (context, _) {
final session = SessionStore.instance;
final aiEnabled = session.aiEnabled;
final showAiEntry = session.isGuest || aiEnabled;
if (!showAiEntry && widget.shell.currentIndex == 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shell.goBranch(0);
});
}
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
@@ -58,12 +50,11 @@ class _MainShellState extends State<MainShell> {
_tab('明细', AppIcons.home, 0),
_tab('统计', AppIcons.chart, 1),
SizedBox(width: 72, child: _fab()),
if (showAiEntry)
ValueListenableBuilder<CompanionDisplay>(
valueListenable: PublicConfigApi.companionNotifier,
builder: (_, companion, __) =>
_tab(companion.name, AppIcons.chat, 2),
),
ValueListenableBuilder<CompanionDisplay>(
valueListenable: PublicConfigApi.companionNotifier,
builder: (_, companion, __) =>
_tab(companion.name, AppIcons.chat, 2),
),
_tab('我的', AppIcons.user, 3),
],
),
@@ -31,37 +31,42 @@ class _OnboardingPageState extends State<OnboardingPage> {
}
Future<void> _loadConfig() async {
if (mounted) setState(() => _loaded = false);
final results = await Future.wait([_loadAvatars(), _loadPersonas()]);
if (!mounted) return;
final avatars = results[0] as List<AvatarItem>;
final personas = results[1] as List<PersonaItem>;
setState(() {
_avatars = avatars;
_personas = personas;
if (!avatars.any((item) => item.key == _avatar) && avatars.isNotEmpty) {
_avatar = avatars.first.key;
}
if (!personas.any((item) => item.key == _persona) && personas.isNotEmpty) {
_persona = personas.first.key;
}
_loaded = true;
});
}
Future<List<AvatarItem>> _loadAvatars() async {
try {
final avatars = await PublicConfigApi.avatars();
final personas = await PublicConfigApi.personas();
if (mounted)
setState(() {
_avatars = avatars;
_personas = personas;
if (avatars.isNotEmpty) _avatar = avatars.first.key;
if (personas.isNotEmpty) _persona = personas.first.key;
_loaded = true;
});
return await PublicConfigApi.avatars();
} catch (_) {
// API 失败用硬编码兜底
if (mounted) setState(() => _loaded = true);
return const [];
}
}
// 兜底数据(API 不可用时)
static const _fallbackAvatars = [
('cat', '小账喵', AppIcons.cat),
('dog', '阿福汪', AppIcons.dog),
('robot', '账小智', AppIcons.robot),
];
static const _fallbackPersonas = [
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽'),
('gentle', '温柔小暖', '永远鼓励,温柔提醒'),
('strict', '严格管家', '理性专业,数据说话'),
('meme', '沙雕损友', '玩梗高手,快乐记账'),
];
Future<List<PersonaItem>> _loadPersonas() async {
try {
return await PublicConfigApi.personas();
} catch (_) {
return const [];
}
}
Future<void> _finish() async {
if (!_hasValidCatalogSelection) return;
setState(() => _saving = true);
try {
await AuthApi.completeOnboarding(
@@ -81,6 +86,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
}
}
bool get _hasValidCatalogSelection =>
_avatars.any((item) => item.key == _avatar) &&
_personas.any((item) => item.key == _persona);
@override
Widget build(BuildContext context) {
if (!_loaded)
@@ -170,9 +179,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
}
Widget _CompanionStep() {
// 优先 API 数据,回退硬编码
final avatars = _avatars.isNotEmpty
? _avatars
final avatars = _avatars
.map(
(a) => (
a.key,
@@ -184,11 +191,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
: AppIcons.cat,
),
)
.toList()
: _fallbackAvatars;
final personas = _personas.isNotEmpty
? _personas.map((p) => (p.key, p.name, p.description)).toList()
: _fallbackPersonas;
.toList();
final personas = _personas
.map((p) => (p.key, p.name, p.description))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -226,6 +232,35 @@ class _OnboardingPageState extends State<OnboardingPage> {
style: TextStyle(fontSize: 12, color: context.jz.text2),
),
SizedBox(height: 14),
if (avatars.isEmpty || personas.isEmpty)
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppIcons.icon(AppIcons.cloud, size: 28, color: context.jz.text3),
const SizedBox(height: 12),
Text(
'AI 伙伴配置暂不可用',
style: TextStyle(
color: context.jz.text,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text('请检查网络后重试', style: TextStyle(color: context.jz.text2, fontSize: 12)),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _loadConfig,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重新加载'),
),
],
),
),
)
else ...[
SizedBox(
height: 96,
child: Row(
@@ -304,7 +339,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
curve: Curves.easeOut,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: on ? context.jz.aiBackground : Colors.white,
color: on ? context.jz.aiBackground : context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: on ? AppTheme.ai : context.jz.line,
@@ -339,11 +374,12 @@ class _OnboardingPageState extends State<OnboardingPage> {
}).toList(),
),
),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _saving ? null : _finish,
onPressed: _saving || !_hasValidCatalogSelection ? null : _finish,
child: _saving
? SizedBox(
height: 20,
@@ -4,6 +4,7 @@ import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
class CategoryManagePage extends StatefulWidget {
@@ -20,6 +21,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
bool _reordering = false;
bool _savingOrder = false;
bool _orderDirty = false;
int _loadRevision = 0;
List<CategoryItem> get _custom =>
_cats.where((category) => category.isCustom).toList();
@@ -31,19 +33,31 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
}
Future<void> _load() async {
final revision = ++_loadRevision;
setState(() => _loading = true);
try {
final cats = await TxApi.categories(_type);
if (mounted) {
final type = _type;
final cached = TxApi.categoriesLocal(type);
if (mounted && revision == _loadRevision) {
setState(() {
_cats = cats;
_cats = cached;
_orderDirty = false;
_loading = false;
});
}
final remote = await TxApi.categoriesRemote(type);
if (mounted && revision == _loadRevision && type == _type) {
setState(() => _cats = remote);
}
} catch (error) {
if (mounted) _showError(error);
if (mounted && revision == _loadRevision && _loading) {
setState(() => _loading = false);
_showError(error);
}
} finally {
if (mounted) setState(() => _loading = false);
if (mounted && revision == _loadRevision && _loading) {
setState(() => _loading = false);
}
}
}
@@ -51,7 +65,13 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
final result = await _showEditor();
if (result == null) return;
try {
await CategoryApi.create(result.name, result.icon, result.color, _type);
await CategoryApi.create(
result.name,
result.icon,
result.color,
_type,
localFirst: true,
);
await _load();
} catch (error) {
if (mounted) _showError(error);
@@ -68,6 +88,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
iconKey: result.icon,
colorKey: result.color,
sortOrder: category.sortOrder,
localFirst: true,
);
await _load();
} catch (error) {
@@ -361,7 +382,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
);
if (!confirmed) return;
try {
await CategoryApi.delete(category.id);
await CategoryApi.delete(category.id, localFirst: true);
await _load();
} catch (error) {
if (mounted) _showError(error);
@@ -396,7 +417,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
final ids = _custom.map((category) => category.id).toList();
setState(() => _savingOrder = true);
try {
await CategoryApi.reorder(_type, ids);
await CategoryApi.reorder(_type, ids, localFirst: true);
if (!mounted) return;
setState(() {
_reordering = false;
@@ -424,6 +445,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
appBar: AppBar(
title: Text('分类管理'),
actions: [
BackendStatusIcon(onRetry: _load),
if (_custom.length > 1)
TextButton(
onPressed: _savingOrder ? null : _toggleReorder,
@@ -5,6 +5,7 @@ import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
/// AI 性格设置页(P5):形象/性格从后台 API 动态拉取
class CompanionPage extends StatefulWidget {
@@ -16,7 +17,9 @@ class CompanionPage extends StatefulWidget {
class _CompanionPageState extends State<CompanionPage> {
String _avatar = 'cat', _persona = 'sassy_cat';
double _roast = 60, _sticker = 70, _proactive = 40;
bool _saving = false, _loaded = false;
bool _saving = false;
bool _refreshing = false;
int _loadRevision = 0;
List<AvatarItem> _avatars = [];
List<PersonaItem> _personas = [];
@@ -40,27 +43,52 @@ class _CompanionPageState extends State<CompanionPage> {
}
Future<void> _load() async {
try {
final p = await AuthApi.me();
if (mounted && p.aiCompanion != null)
setState(() {
_avatar = p.aiCompanion!.avatarKey;
_persona = p.aiCompanion!.personaKey;
_roast = p.aiCompanion!.roastLevel.toDouble();
_sticker = p.aiCompanion!.stickerFrequency.toDouble();
_proactive = p.aiCompanion!.proactiveLevel.toDouble();
});
} catch (_) {}
try {
final av = await PublicConfigApi.avatars();
final ps = await PublicConfigApi.personas();
if (mounted)
setState(() {
_avatars = av;
_personas = ps;
});
} catch (_) {}
if (mounted) setState(() => _loaded = true);
final revision = ++_loadRevision;
setState(() => _refreshing = true);
final cached = await Future.wait<Object?>([
AuthApi.cachedCompanion(),
PublicConfigApi.cachedAvatars(),
PublicConfigApi.cachedPersonas(),
]);
if (!mounted || revision != _loadRevision) return;
_applyCompanion(cached[0] as AiCompanion?);
setState(() {
_avatars = cached[1] as List<AvatarItem>;
_personas = cached[2] as List<PersonaItem>;
});
await Future.wait([
AuthApi.me(forceRemote: true).then<void>((profile) {
if (mounted && revision == _loadRevision) {
_applyCompanion(profile.aiCompanion);
}
}, onError: (_) {}),
PublicConfigApi.avatars().then<void>((values) {
if (mounted && revision == _loadRevision) {
setState(() => _avatars = values);
}
}, onError: (_) {}),
PublicConfigApi.personas().then<void>((values) {
if (mounted && revision == _loadRevision) {
setState(() => _personas = values);
}
}, onError: (_) {}),
]);
if (mounted && revision == _loadRevision) {
setState(() => _refreshing = false);
}
}
void _applyCompanion(AiCompanion? companion) {
if (companion == null || !mounted) return;
setState(() {
_avatar = companion.avatarKey;
_persona = companion.personaKey;
_roast = companion.roastLevel.toDouble();
_sticker = companion.stickerFrequency.toDouble();
_proactive = companion.proactiveLevel.toDouble();
});
}
Future<void> _save() async {
@@ -80,26 +108,20 @@ class _CompanionPageState extends State<CompanionPage> {
context,
).showSnackBar(const SnackBar(content: Text('已保存')));
} catch (e) {
if (mounted)
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
}
} finally {
if (mounted) setState(() => _saving = false);
if (mounted) {
setState(() => _saving = false);
}
}
}
@override
Widget build(BuildContext context) {
if (!_loaded)
return const Scaffold(
body: Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
),
);
final avatars = _avatars.isNotEmpty
? _avatars
.map(
@@ -122,7 +144,16 @@ class _CompanionPageState extends State<CompanionPage> {
: _fallbackPersonas;
return Scaffold(
appBar: AppBar(title: Text('AI 性格设置')),
appBar: AppBar(
title: Text('AI 性格设置'),
actions: [BackendStatusIcon(onRetry: _load)],
bottom: _refreshing
? const PreferredSize(
preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(minHeight: 2),
)
: null,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@@ -236,19 +267,24 @@ class _CompanionPageState extends State<CompanionPage> {
),
),
SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _saving ? null : _save,
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('保存设置'),
ValueListenableBuilder<BackendAvailability>(
valueListenable: ApiClient.availability,
builder: (context, availability, _) => ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: availability == BackendAvailability.online && !_saving
? _save
: null,
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('保存设置'),
),
),
],
),
@@ -264,13 +264,6 @@ class _MePageState extends State<MePage> {
'预算管理',
onTap: () => context.push('/budget'),
),
_row(
AppIcons.avatarAsset(PublicConfigApi.companionAvatarKey),
context.jz.aiBackground,
AppTheme.ai,
session.aiEnabled ? 'AI 报告' : '报告',
onTap: () => context.push('/report'),
),
_row(
AppIcons.tag,
context.jz.primaryBackground,
@@ -6,6 +6,7 @@ import 'package:miaoji_zhang/shared/services/transaction_events.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
class RecycleBinPage extends StatefulWidget {
const RecycleBinPage({super.key});
@@ -17,6 +18,7 @@ class RecycleBinPage extends StatefulWidget {
class _RecycleBinPageState extends State<RecycleBinPage> {
List<TxItem> _items = const [];
bool _loading = true;
int _loadRevision = 0;
@override
void initState() {
@@ -25,15 +27,30 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
}
Future<void> _load() async {
final revision = ++_loadRevision;
setState(() => _loading = true);
try {
await CurrentLedgerStore.instance.ensureLoaded();
final items = await TxApi.recycleBin();
if (mounted) setState(() => _items = items);
await CurrentLedgerStore.instance.loadCached();
final cached = TxApi.recycleBinLocal();
if (mounted && revision == _loadRevision) {
setState(() {
_items = cached;
_loading = false;
});
}
final remote = await TxApi.recycleBinRemote();
if (mounted && revision == _loadRevision) {
setState(() => _items = remote);
}
} catch (error) {
if (mounted) _showError(error);
if (mounted && revision == _loadRevision && _loading) {
setState(() => _loading = false);
_showError(error);
}
} finally {
if (mounted) setState(() => _loading = false);
if (mounted && revision == _loadRevision && _loading) {
setState(() => _loading = false);
}
}
}
@@ -48,6 +65,7 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
}
Future<void> _permanentDelete(TxItem item) async {
if (ApiClient.availability.value != BackendAvailability.online) return;
final confirmed = await _confirm('永久删除', '永久删除后无法恢复,聊天中的账单卡片会显示为已删除。');
if (!confirmed) return;
try {
@@ -59,7 +77,10 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
}
Future<void> _clear() async {
if (_items.isEmpty) return;
if (_items.isEmpty ||
ApiClient.availability.value != BackendAvailability.online) {
return;
}
final confirmed = await _confirm('清空回收站', '将永久删除当前账本回收站中的全部账单,无法恢复。');
if (!confirmed) return;
try {
@@ -79,12 +100,14 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
);
Future<void> _showActions(TxItem item) async {
final online = ApiClient.availability.value == BackendAvailability.online;
final action = await showJzOptionSheet<String>(
context,
title: item.note ?? item.categoryName,
options: const [
JzOption(value: 'restore', label: '恢复账单'),
JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
options: [
const JzOption(value: 'restore', label: '恢复账单'),
if (online)
const JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
],
);
if (action == 'restore') await _restore(item);
@@ -103,9 +126,16 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
appBar: AppBar(
title: Text('${CurrentLedgerStore.instance.currentName} · 回收站'),
actions: [
TextButton(
onPressed: _items.isEmpty ? null : _clear,
child: Text('清空'),
BackendStatusIcon(onRetry: _load),
ValueListenableBuilder<BackendAvailability>(
valueListenable: ApiClient.availability,
builder: (context, availability, _) => TextButton(
onPressed:
_items.isEmpty || availability != BackendAvailability.online
? null
: _clear,
child: Text('清空'),
),
),
],
),
@@ -27,6 +27,9 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
Timer? _diagnosticRefreshTimer;
Timer? _previewExpiryTimer;
bool _diagnosticRefreshInFlight = false;
bool _connectionCheckInFlight = false;
bool _keepAliveStarting = false;
bool _recentsProtectionNoticeChecked = false;
@override
void initState() {
@@ -60,14 +63,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
void _refreshRunningDiagnostic() {
if (!mounted ||
_diagnosticRefreshInFlight ||
_status?.latestDiagnostic?.result != 'started') {
!{'started', 'waiting'}.contains(_status?.latestDiagnostic?.result)) {
return;
}
_diagnosticRefreshInFlight = true;
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
}
Future<void> _check() async {
Future<void> _check({bool monitorConnection = true}) async {
try {
var status = await ScreenshotChannel.recognitionStatus();
final invalid = <String>[
@@ -97,6 +100,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
_error = null;
});
_schedulePreviewExpiry(status);
unawaited(_showRecentsProtectionNotice(status));
if (monitorConnection &&
status.accessibilityAuthorized &&
!status.accessibilityConnected &&
!status.taskCleanerRecoveryNeeded &&
(status.accessibilityEvents || status.aiScreenshot)) {
unawaited(_monitorAccessibilityConnection());
}
} catch (_) {
if (!mounted) return;
setState(() {
@@ -106,6 +117,49 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
}
}
Future<void> _showRecentsProtectionNotice(RecognitionStatus status) async {
if (_recentsProtectionNoticeChecked || !status.recentsProtectionActive) {
return;
}
_recentsProtectionNoticeChecked = true;
final prefs = await SharedPreferences.getInstance();
const key = 'originos_recents_protection_notice_v1';
if (prefs.getBool(key) == true || !mounted) return;
await prefs.setBool(key, true);
if (!mounted) return;
_showMessage('记之已从最近任务隐藏,可从桌面图标重新打开;关闭全部识别后恢复。');
}
Future<void> _monitorAccessibilityConnection({
bool ensureKeepAlive = false,
}) async {
if (_connectionCheckInFlight) return;
_connectionCheckInFlight = true;
if (mounted) setState(() {});
try {
if (ensureKeepAlive) {
_keepAliveStarting = true;
if (mounted) setState(() {});
await ScreenshotChannel.ensureRecognitionKeepAlive();
}
await ScreenshotChannel.waitForAccessibilityConnection();
final status = await ScreenshotChannel.recognitionStatus();
if (!mounted) return;
setState(() {
_status = status;
_error = null;
});
_schedulePreviewExpiry(status);
} catch (_) {
if (!mounted) return;
setState(() => _error = '连接状态复核失败,请重试');
} finally {
_connectionCheckInFlight = false;
_keepAliveStarting = false;
if (mounted) setState(() {});
}
}
void _schedulePreviewExpiry(RecognitionStatus status) {
_previewExpiryTimer?.cancel();
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
@@ -431,7 +485,32 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
authorized: status!.accessibilityAuthorized,
connected: status.accessibilityConnected,
statusLabel:
_connectionCheckInFlight && !status.accessibilityConnected
? '正在连接'
: status.accessibilityConnectionLabel,
recoveryMessage: status.accessibilityNeedsRecovery
? status.taskCleanerRecoveryNeeded
? 'OriginOS 清理了识别进程,系统仍保留授权,但无障碍服务已被标记故障。'
: '系统仍显示已授权,但 OriginOS 未重新绑定服务。请前往系统设置,将记之无障碍服务关闭后重新开启。'
: (status.accessibilityEvents || status.aiScreenshot) &&
status.keepAliveNeedsRecovery
? _keepAliveStarting
? '正在启动后台保护…'
: status.keepAliveError ?? '后台保护未运行,划掉应用后识别可能中断。'
: null,
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
settingsLabel: '前往无障碍设置',
onRetry: status.accessibilityNeedsRecovery
? () => _monitorAccessibilityConnection()
: null,
onStartKeepAlive:
(status.accessibilityEvents || status.aiScreenshot) &&
status.keepAliveNeedsRecovery &&
!_keepAliveStarting
? () =>
_monitorAccessibilityConnection(ensureKeepAlive: true)
: null,
child: Column(
children: [
JzSwitchTile(
@@ -538,7 +617,12 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
status.notificationEvents ||
status.aiScreenshot) ...[
const SizedBox(height: 10),
_BackgroundKeepAliveCard(status: status),
_BackgroundKeepAliveCard(
status: status,
starting: _keepAliveStarting,
onStart: () =>
_monitorAccessibilityConnection(ensureKeepAlive: true),
),
],
const SizedBox(height: 10),
_RecognitionCard(
@@ -836,8 +920,14 @@ class _EvidencePill extends StatelessWidget {
class _BackgroundKeepAliveCard extends StatelessWidget {
final RecognitionStatus status;
final bool starting;
final VoidCallback onStart;
const _BackgroundKeepAliveCard({required this.status});
const _BackgroundKeepAliveCard({
required this.status,
required this.starting,
required this.onStart,
});
@override
Widget build(BuildContext context) {
@@ -875,9 +965,18 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
),
),
Text(
status.batteryOptimizationIgnored ? '后台限制较少' : '需要设置',
status.recentsProtectionActive
? '任务清理防护中'
: status.keepAliveRunning
? '保护运行中'
: status.keepAliveExpected
? '保护未运行'
: status.batteryOptimizationIgnored
? '后台限制较少'
: '需要设置',
style: TextStyle(
color: status.batteryOptimizationIgnored
color:
status.keepAliveRunning || status.recentsProtectionActive
? AppTheme.primaryDeep
: AppTheme.orange,
fontSize: 10,
@@ -888,15 +987,29 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
),
const SizedBox(height: 10),
Text(
'无障碍和通知监听由 Android 独立轻量进程运行。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
status.recentsProtectionActive
? 'OriginOS 最近任务保护已开启。记之不会显示在最近任务中,请从桌面图标重新打开;关闭全部识别后会自动恢复任务卡。'
: '无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
style: TextStyle(
color: context.jz.text2,
fontSize: 11.5,
height: 1.55,
),
),
if (status.keepAliveNeedsRecovery) ...[
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: JzActionButton(
label: starting ? '正在启动…' : '启动后台保护',
secondary: true,
icon: const Icon(Icons.shield_outlined, size: 18),
onPressed: starting ? null : onStart,
),
),
],
const SizedBox(height: 12),
Row(
children: [
@@ -932,6 +1045,10 @@ class _RecognitionCard extends StatelessWidget {
final String? statusLabel;
final Widget child;
final VoidCallback? onOpenSettings;
final VoidCallback? onRetry;
final VoidCallback? onStartKeepAlive;
final String? recoveryMessage;
final String settingsLabel;
const _RecognitionCard({
required this.icon,
@@ -942,12 +1059,16 @@ class _RecognitionCard extends StatelessWidget {
required this.child,
this.statusLabel,
this.onOpenSettings,
this.onRetry,
this.onStartKeepAlive,
this.recoveryMessage,
this.settingsLabel = '前往系统设置',
});
@override
Widget build(BuildContext context) {
final palette = context.jz;
final color = connected ? AppTheme.primary : AppTheme.ai;
final color = connected ? AppTheme.primary : AppTheme.orange;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -966,7 +1087,7 @@ class _RecognitionCard extends StatelessWidget {
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
: palette.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 21),
@@ -986,7 +1107,7 @@ class _RecognitionCard extends StatelessWidget {
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
: palette.warningBackground,
borderRadius: BorderRadius.circular(20),
),
child: Text(
@@ -1010,44 +1131,59 @@ class _RecognitionCard extends StatelessWidget {
description,
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
),
const SizedBox(height: 6),
child,
if (onOpenSettings != null) ...[
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: Semantics(
button: true,
label: '打开系统权限设置',
child: InkWell(
onTap: onOpenSettings,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.settings_outlined,
size: 17,
color: AppTheme.primary,
),
const SizedBox(width: 6),
Text(
'系统权限设置',
style: TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.w700,
),
),
],
if (recoveryMessage != null) ...[
const SizedBox(height: 9),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline_rounded,
color: AppTheme.orange,
size: 18,
),
const SizedBox(width: 7),
Expanded(
child: Text(
recoveryMessage!,
style: TextStyle(
color: palette.text2,
fontSize: 11.5,
height: 1.5,
),
),
),
),
],
),
],
const SizedBox(height: 6),
child,
if (onOpenSettings != null ||
onRetry != null ||
onStartKeepAlive != null) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (onRetry != null)
TextButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重新检测'),
),
if (onStartKeepAlive != null)
TextButton.icon(
onPressed: onStartKeepAlive,
icon: const Icon(Icons.shield_outlined, size: 18),
label: const Text('启动后台保护'),
),
if (onOpenSettings != null)
TextButton.icon(
onPressed: onOpenSettings,
icon: const Icon(Icons.settings_outlined, size: 18),
label: Text(settingsLabel),
),
],
),
],
],
+35 -12
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
@@ -6,6 +8,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
@@ -13,7 +16,10 @@ import 'package:miaoji_zhang/shared/services/session_store.dart';
enum _ReportKind { weekly, monthly, yearly }
class ReportPage extends StatefulWidget {
const ReportPage({super.key});
final String? initialPeriod;
final DateTime? initialAnchor;
const ReportPage({super.key, this.initialPeriod, this.initialAnchor});
@override
State<ReportPage> createState() => _ReportPageState();
@@ -25,13 +31,20 @@ class _ReportPageState extends State<ReportPage> {
String? _error;
DateTime _anchor = ShanghaiTime.now;
_ReportKind _kind = _ReportKind.monthly;
int _loadRevision = 0;
@override
void initState() {
super.initState();
_anchor = widget.initialAnchor ?? ShanghaiTime.now;
_kind = switch (widget.initialPeriod) {
'week' => _ReportKind.weekly,
'year' => _ReportKind.yearly,
_ => _ReportKind.monthly,
};
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
CurrentLedgerStore.instance.addListener(_load);
_load();
unawaited(_load());
}
@override
@@ -46,24 +59,33 @@ class _ReportPageState extends State<ReportPage> {
}
Future<void> _load() async {
final revision = ++_loadRevision;
setState(() {
_loading = true;
_error = null;
});
try {
final report = switch (_kind) {
_ReportKind.weekly => await ReportApi.weekly(_anchor),
_ReportKind.monthly => await ReportApi.monthlyPeriod(
_anchor.year,
_anchor.month,
),
_ReportKind.yearly => await ReportApi.yearly(_anchor.year),
final period = switch (_kind) {
_ReportKind.weekly => 'week',
_ReportKind.monthly => 'month',
_ReportKind.yearly => 'year',
};
if (mounted) setState(() => _report = report);
final local = ReportApi.local(period, _anchor);
if (mounted && revision == _loadRevision) {
setState(() => _report = local);
}
final remote = await ReportApi.remote(period, _anchor);
if (mounted && revision == _loadRevision) {
setState(() => _report = remote);
}
} catch (error) {
if (mounted) setState(() => _error = apiErrorMessage(error));
if (mounted && revision == _loadRevision && _report == null) {
setState(() => _error = apiErrorMessage(error));
}
} finally {
if (mounted) setState(() => _loading = false);
if (mounted && revision == _loadRevision) {
setState(() => _loading = false);
}
}
}
@@ -108,6 +130,7 @@ class _ReportPageState extends State<ReportPage> {
return Scaffold(
appBar: AppBar(
title: Text(SessionStore.instance.aiEnabled ? 'AI 报告' : '报告'),
actions: [BackendStatusIcon(onRetry: _load)],
),
body: Column(
children: [
+125 -43
View File
@@ -1,13 +1,18 @@
import 'dart:math' as math;
import 'dart:async';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
class StatsPage extends StatefulWidget {
@@ -23,12 +28,13 @@ class _StatsPageState extends State<StatsPage> {
PeriodStats? _stats;
String? _error;
bool _loading = true;
int _loadRevision = 0;
@override
void initState() {
super.initState();
CurrentLedgerStore.instance.addListener(_load);
_load();
unawaited(_load());
}
@override
@@ -38,6 +44,7 @@ class _StatsPageState extends State<StatsPage> {
}
Future<void> _load() async {
final revision = ++_loadRevision;
if (mounted) {
setState(() {
_loading = true;
@@ -45,12 +52,25 @@ class _StatsPageState extends State<StatsPage> {
});
}
try {
final stats = await TxApi.periodStats(_period, _anchor);
if (mounted) setState(() => _stats = stats);
final local = TxApi.periodStatsLocal(_period, _anchor);
if (mounted && revision == _loadRevision) {
setState(() => _stats = local);
}
} catch (error) {
if (mounted) setState(() => _error = apiErrorMessage(error));
if (mounted && revision == _loadRevision) {
setState(() => _error = apiErrorMessage(error));
}
}
try {
final remote = await TxApi.periodStatsRemote(_period, _anchor);
if (mounted && revision == _loadRevision) {
setState(() => _stats = remote);
}
} catch (_) {
} finally {
if (mounted) setState(() => _loading = false);
if (mounted && revision == _loadRevision) {
setState(() => _loading = false);
}
}
}
@@ -93,7 +113,10 @@ class _StatsPageState extends State<StatsPage> {
Widget build(BuildContext context) {
final stats = _stats;
return Scaffold(
appBar: AppBar(title: Text('统计')),
appBar: AppBar(
title: Text('统计'),
actions: [BackendStatusIcon(onRetry: _load)],
),
body: Column(
children: [
Padding(
@@ -105,7 +128,7 @@ class _StatsPageState extends State<StatsPage> {
JzOption(value: 'month', label: ''),
JzOption(value: 'year', label: ''),
],
onChanged: _loading ? null : _selectPeriod,
onChanged: _selectPeriod,
),
),
Expanded(
@@ -141,8 +164,7 @@ class _StatsPageState extends State<StatsPage> {
else ...[
_categoryCard(stats),
SizedBox(height: 10),
if (stats.analysis case final analysis?)
_analysisCard(analysis),
_reportCard(stats.analysis),
SizedBox(height: 10),
_trendCard(stats),
if (stats.byCategory.isNotEmpty) ...[
@@ -171,7 +193,7 @@ class _StatsPageState extends State<StatsPage> {
children: [
IconButton(
tooltip: '上一个周期',
onPressed: _loading ? null : () => _shift(-1),
onPressed: () => _shift(-1),
icon: Icon(Icons.chevron_left_rounded),
),
SizedBox(
@@ -184,9 +206,7 @@ class _StatsPageState extends State<StatsPage> {
),
IconButton(
tooltip: '下一个周期',
onPressed: _loading || _isAfterCurrentPeriod(nextAnchor)
? null
: () => _shift(1),
onPressed: _isAfterCurrentPeriod(nextAnchor) ? null : () => _shift(1),
icon: Icon(Icons.chevron_right_rounded),
),
],
@@ -300,41 +320,103 @@ class _StatsPageState extends State<StatsPage> {
);
}
Widget _analysisCard(String text) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: context.jz.aiBackground,
Widget _reportCard(String? analysis) {
final aiEnabled = SessionStore.instance.aiEnabled;
final accent = aiEnabled ? AppTheme.ai : AppTheme.primary;
final background = aiEnabled
? context.jz.aiBackground
: context.jz.primaryBackground;
final copy = analysis?.trim().isNotEmpty == true
? analysis!.trim()
: '查看本周期的收支变化、分类排行和消费高峰。';
final route = Uri(
path: '/report',
queryParameters: {'period': _period, 'date': _anchor.toIso8601String()},
).toString();
return Semantics(
button: true,
label: aiEnabled ? '查看 AI 报告' : '查看周期报告',
child: Material(
color: background,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.ai.withValues(alpha: 0.12)),
),
child: Row(
children: [
Container(
width: 30,
height: 30,
child: InkWell(
onTap: () => context.push(route),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: accent.withValues(alpha: 0.12)),
),
child: Icon(
Icons.auto_awesome_rounded,
size: 16,
color: AppTheme.ai,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
aiEnabled
? Icons.auto_awesome_rounded
: Icons.assessment_outlined,
size: 17,
color: accent,
),
),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
aiEnabled
? '${PublicConfigApi.companionName}的 AI 报告'
: '周期报告',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: accent,
),
),
SizedBox(height: 5),
Text(
copy,
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
height: 1.5,
),
),
SizedBox(height: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'查看完整报告',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: accent,
),
),
SizedBox(width: 2),
Icon(
Icons.chevron_right_rounded,
size: 17,
color: accent,
),
],
),
],
),
),
],
),
),
SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
height: 1.5,
),
),
),
],
),
),
);
}
+28 -1
View File
@@ -4,6 +4,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
enum BackendAvailability { unknown, online, offline }
class ApiClient {
ApiClient._();
static final ApiClient instance = ApiClient._();
@@ -12,6 +14,9 @@ class ApiClient {
static const _legacyTokenKey = 'auth_token';
static final _tokenKey = 'auth_token_${BackendIdentity.scope}';
static final sessionExpired = ValueNotifier<int>(0);
static final availability = ValueNotifier<BackendAvailability>(
BackendAvailability.unknown,
);
static bool _handlingUnauthorized = false;
static const String baseUrl = BackendIdentity.baseUrl;
@@ -31,6 +36,10 @@ class ApiClient {
)
..interceptors.add(
InterceptorsWrapper(
onResponse: (response, handler) {
availability.value = BackendAvailability.online;
handler.next(response);
},
onRequest: (options, handler) async {
final token = await _storage.read(key: _tokenKey);
if (token != null) {
@@ -39,6 +48,11 @@ class ApiClient {
handler.next(options);
},
onError: (error, handler) async {
if (isConnectivityError(error)) {
availability.value = BackendAvailability.offline;
} else if (error.response != null) {
availability.value = BackendAvailability.online;
}
final unauthorized = error.response?.statusCode == 401;
final data = error.response?.data;
final aiDenied =
@@ -74,6 +88,18 @@ class ApiClient {
}
Future<String?> readToken() => _storage.read(key: _tokenKey);
Future<void> probe() async {
try {
await dio.get<void>(
'/api/public/brand',
options: Options(receiveTimeout: const Duration(seconds: 10)),
);
} catch (_) {
// The interceptor owns the reachability state transition.
}
}
Future<void> clearToken() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _legacyTokenKey);
@@ -91,8 +117,9 @@ String apiErrorMessage(Object e) {
if (e is StateError) return e.message;
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['message'] != null)
if (data is Map && data['message'] != null) {
return data['message'] as String;
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.receiveTimeout) {
+48 -9
View File
@@ -1,5 +1,9 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/backend_identity.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';
@@ -19,6 +23,15 @@ class AiCompanion {
roastLevel = json['roastLevel'] as int,
stickerFrequency = json['stickerFrequency'] as int,
proactiveLevel = json['proactiveLevel'] as int;
Map<String, dynamic> toJson() => {
'avatarKey': avatarKey,
'personaKey': personaKey,
'customName': customName,
'roastLevel': roastLevel,
'stickerFrequency': stickerFrequency,
'proactiveLevel': proactiveLevel,
};
}
class UserProfile {
@@ -49,8 +62,9 @@ class UserProfile {
required this.nickname,
required this.appMode,
required this.onboardingDone,
this.aiCompanion,
this.aiEnabled = false,
}) : aiCompanion = null;
});
}
class AuthLoginResult {
@@ -120,6 +134,20 @@ class AuthApi {
}
}
static Future<AiCompanion?> cachedCompanion() async {
final userId = SessionStore.instance.userId;
if (userId == null) return null;
final value = (await SharedPreferences.getInstance()).getString(
_companionCacheKey(userId),
);
if (value == null) return null;
try {
return AiCompanion.fromJson(jsonDecode(value) as Map<String, dynamic>);
} catch (_) {
return null;
}
}
static Future<UserProfile> completeOnboarding({
required String appMode,
required String avatarKey,
@@ -272,13 +300,24 @@ class AuthApi {
);
}
static Future<void> _cacheProfile(UserProfile profile) =>
SessionStore.instance.activateAccount(
userId: profile.userId,
username: profile.username,
nickname: profile.nickname,
appMode: profile.appMode,
onboardingDone: profile.onboardingDone,
aiEnabled: profile.aiEnabled,
static Future<void> _cacheProfile(UserProfile profile) async {
await SessionStore.instance.activateAccount(
userId: profile.userId,
username: profile.username,
nickname: profile.nickname,
appMode: profile.appMode,
onboardingDone: profile.onboardingDone,
aiEnabled: profile.aiEnabled,
);
final companion = profile.aiCompanion;
if (companion != null) {
await (await SharedPreferences.getInstance()).setString(
_companionCacheKey(profile.userId),
jsonEncode(companion.toJson()),
);
}
}
static String _companionCacheKey(int userId) =>
'ai_companion_${BackendIdentity.scope}_$userId';
}
+199 -30
View File
@@ -8,6 +8,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.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/shanghai_time.dart';
import 'package:miaoji_zhang/shared/services/sync_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class TxItem {
@@ -244,6 +245,20 @@ class TxApi {
static bool get _queueOfflineChanges =>
SessionStore.instance.isAccount && SessionStore.instance.cloudSyncEnabled;
static MonthSummary monthLocal(int year, int month) => MonthSummary.fromJson(
LocalDatabase.instance.monthSummary(year, month, _ledgerId),
);
static Future<MonthSummary> monthRemote(int year, int month) async {
final response = await _dio.get(
'/api/transactions/month',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheMonthSummary(json);
return MonthSummary.fromJson(json);
}
static Future<MonthSummary> month(int year, int month) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
@@ -330,13 +345,33 @@ class TxApi {
}
}
static PeriodStats periodStatsLocal(String period, DateTime anchor) =>
PeriodStats.fromJson(
LocalDatabase.instance.periodStats(period, anchor, _ledgerId),
);
static Future<PeriodStats> periodStatsRemote(
String period,
DateTime anchor,
) async {
final response = await _dio.get(
'/api/transactions/stats/period',
queryParameters: {
'period': period,
'anchor':
'${anchor.year.toString().padLeft(4, '0')}-'
'${anchor.month.toString().padLeft(2, '0')}-'
'${anchor.day.toString().padLeft(2, '0')}',
'ledgerId': _ledgerId,
},
);
return PeriodStats.fromJson(response.data as Map<String, dynamic>);
}
static Future<List<CategoryItem>> categories(String type) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
return LocalDatabase.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList();
return categoriesLocal(type);
}
try {
final response = await _dio.get(
@@ -350,13 +385,32 @@ class TxApi {
.toList();
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return LocalDatabase.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList();
return categoriesLocal(type);
}
}
static List<CategoryItem> categoriesLocal(String type) {
LocalDatabase.instance.ensureDefaultCategories(type: type);
return LocalDatabase.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList();
}
static Future<List<CategoryItem>> categoriesRemote(String type) async {
final response = await _dio.get(
'/api/categories',
queryParameters: {'type': type},
);
final values = response.data as List;
LocalDatabase.instance.cacheCategories(values, replaceType: type);
LocalDatabase.instance.ensureDefaultCategories(type: type);
return LocalDatabase.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList();
}
static Future<TxItem> create({
required int categoryId,
required String type,
@@ -374,6 +428,7 @@ class TxApi {
String? recognitionOccurrenceId,
String? evidenceFingerprint,
String? recognitionConfidence,
bool localFirst = false,
}) async {
final payload = <String, dynamic>{
'ledgerId': _ledgerId,
@@ -401,7 +456,10 @@ class TxApi {
'recognitionConfidence': recognitionConfidence,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || categoryId < 0 || _ledgerId < 0) {
if (localFirst ||
session.shouldUseLocalOnly ||
categoryId < 0 ||
_ledgerId < 0) {
final local = LocalDatabase.instance.createTransaction(payload);
if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync(
@@ -410,6 +468,7 @@ class TxApi {
'create',
payload,
);
_scheduleBackgroundSync();
}
return TxItem.fromJson(local);
}
@@ -532,7 +591,9 @@ class TxApi {
includeDeleted: true,
)?['updatedAt'];
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
id < 0) {
LocalDatabase.instance.softDeleteTransaction(id);
if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync('transaction', id, 'delete', {
@@ -613,7 +674,8 @@ class TxApi {
static Future<List<TxItem>> recycleBin() async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline) {
return LocalDatabase.instance
.recycleBin(_ledgerId)
.map(TxItem.fromJson)
@@ -638,13 +700,32 @@ class TxApi {
}
}
static List<TxItem> recycleBinLocal() => LocalDatabase.instance
.recycleBin(_ledgerId)
.map(TxItem.fromJson)
.toList();
static Future<List<TxItem>> recycleBinRemote() async {
final response = await _dio.get(
'/api/transactions/recycle-bin',
queryParameters: {'ledgerId': _ledgerId},
);
final values = response.data as List;
LocalDatabase.instance.cacheTransactions(values);
return values
.map((item) => TxItem.fromJson(item as Map<String, dynamic>))
.toList();
}
static Future<TxItem> restore(int id) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
includeDeleted: true,
)?['updatedAt'];
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
id < 0) {
final local = LocalDatabase.instance.restoreTransaction(id);
if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync('transaction', id, 'restore', {
@@ -984,6 +1065,18 @@ class BudgetApi {
static final _dio = ApiClient.instance.dio;
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
static BudgetsData getLocal(int year, int month) => BudgetsData.fromJson(
LocalDatabase.instance.budgets(year, month, _ledgerId),
);
static Future<BudgetsData> getRemote(int year, int month) async {
final response = await _dio.get(
'/api/budgets',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
return BudgetsData.fromJson(response.data as Map<String, dynamic>);
}
static Future<BudgetsData> get(int year, int month) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
@@ -1294,6 +1387,29 @@ class ReportApi {
static final _dio = ApiClient.instance.dio;
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
static PeriodReport local(String period, DateTime anchor) =>
_localPeriod(period, anchor);
static Future<PeriodReport> remote(String period, DateTime anchor) async {
if (period == 'month') {
return PeriodReport.fromMonthly(await monthly(anchor.year, anchor.month));
}
final path = period == 'week'
? '/api/reports/weekly'
: '/api/reports/yearly';
final query = period == 'week'
? {
'date':
'${anchor.year.toString().padLeft(4, '0')}-'
'${anchor.month.toString().padLeft(2, '0')}-'
'${anchor.day.toString().padLeft(2, '0')}',
'ledgerId': _ledgerId,
}
: {'year': anchor.year, 'ledgerId': _ledgerId};
final response = await _dio.get(path, queryParameters: query);
return PeriodReport.fromJson(response.data as Map<String, dynamic>);
}
static Future<PeriodReport> weekly(DateTime date) async {
if (SessionStore.instance.shouldUseLocalOnly) {
return _localPeriod('week', date);
@@ -1586,8 +1702,9 @@ class CategoryApi {
String name,
String iconKey,
String colorKey,
String type,
) async {
String type, {
bool localFirst = false,
}) async {
final payload = {
'name': name,
'iconKey': iconKey,
@@ -1595,7 +1712,9 @@ class CategoryApi {
'type': type,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
if (localFirst ||
session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline) {
final local = LocalDatabase.instance.createCategory(
name,
iconKey,
@@ -1609,6 +1728,7 @@ class CategoryApi {
'create',
payload,
);
_scheduleBackgroundSync();
}
return CategoryItem.fromJson(local);
}
@@ -1641,6 +1761,7 @@ class CategoryApi {
required String iconKey,
required String colorKey,
required int sortOrder,
bool localFirst = false,
}) async {
final payload = {
'name': name,
@@ -1649,7 +1770,10 @@ class CategoryApi {
'sortOrder': sortOrder,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
if (localFirst ||
session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
id < 0) {
final local = LocalDatabase.instance.updateCategory(
id,
name,
@@ -1659,45 +1783,90 @@ class CategoryApi {
);
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
_scheduleBackgroundSync();
}
return CategoryItem.fromJson(local);
}
final response = await _dio.put('/api/categories/$id', data: payload);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheCategories([json]);
return CategoryItem.fromJson(json);
try {
final response = await _dio.put('/api/categories/$id', data: payload);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheCategories([json]);
return CategoryItem.fromJson(json);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
final local = LocalDatabase.instance.updateCategory(
id,
name,
iconKey,
colorKey,
sortOrder,
);
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
return CategoryItem.fromJson(local);
}
}
static Future<void> reorder(String type, List<int> categoryIds) async {
static Future<void> reorder(
String type,
List<int> categoryIds, {
bool localFirst = false,
}) async {
LocalDatabase.instance.reorderCategories(type, categoryIds);
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || categoryIds.any((id) => id < 0)) {
if (localFirst ||
session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
categoryIds.any((id) => id < 0)) {
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
'type': type,
'categoryIds': categoryIds,
});
_scheduleBackgroundSync();
}
return;
}
await _dio.put(
'/api/categories/reorder',
data: {'type': type, 'categoryIds': categoryIds},
);
try {
await _dio.put(
'/api/categories/reorder',
data: {'type': type, 'categoryIds': categoryIds},
);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
'type': type,
'categoryIds': categoryIds,
});
}
}
static Future<void> delete(int id) async {
static Future<void> delete(int id, {bool localFirst = false}) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
if (localFirst ||
session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
id < 0) {
LocalDatabase.instance.deleteCategory(id);
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
'id': id,
});
_scheduleBackgroundSync();
}
return;
}
await _dio.delete('/api/categories/$id');
LocalDatabase.instance.deleteCategory(id);
try {
await _dio.delete('/api/categories/$id');
LocalDatabase.instance.deleteCategory(id);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
LocalDatabase.instance.deleteCategory(id);
LocalDatabase.instance.enqueueSync('category', id, 'delete', {'id': id});
}
}
}
void _scheduleBackgroundSync() {
SyncService.instance.refreshLocalStatus();
unawaited(SyncService.instance.run());
}
+63 -2
View File
@@ -1,5 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
class BrandConfig {
@@ -41,6 +45,13 @@ class AvatarItem {
defaultName = j['defaultName'] as String,
speechTic = j['speechTic'] as String? ?? '',
imageUrl = j['imageUrl'] as String?;
Map<String, dynamic> toJson() => {
'key': key,
'defaultName': defaultName,
'speechTic': speechTic,
'imageUrl': imageUrl,
};
}
class PersonaItem {
@@ -50,6 +61,13 @@ class PersonaItem {
name = j['name'] as String,
description = j['description'] as String? ?? '',
sampleLine = j['sampleLine'] as String? ?? '';
Map<String, dynamic> toJson() => {
'key': key,
'name': name,
'description': description,
'sampleLine': sampleLine,
};
}
class CompanionDisplay {
@@ -145,16 +163,59 @@ class PublicConfigApi {
static Future<List<AvatarItem>> avatars() async {
final res = await _dio.get('/api/public/avatars');
return (res.data as List)
final values = (res.data as List)
.map((e) => AvatarItem.fromJson(e as Map<String, dynamic>))
.toList();
await _cacheCatalog(
_avatarCacheKey,
values.map((e) => e.toJson()).toList(),
);
return values;
}
static Future<List<PersonaItem>> personas() async {
final res = await _dio.get('/api/public/personas');
return (res.data as List)
final values = (res.data as List)
.map((e) => PersonaItem.fromJson(e as Map<String, dynamic>))
.toList();
await _cacheCatalog(
_personaCacheKey,
values.map((e) => e.toJson()).toList(),
);
return values;
}
static Future<List<AvatarItem>> cachedAvatars() async =>
(await _cachedCatalog(_avatarCacheKey)).map(AvatarItem.fromJson).toList();
static Future<List<PersonaItem>> cachedPersonas() async =>
(await _cachedCatalog(
_personaCacheKey,
)).map(PersonaItem.fromJson).toList();
static String get _avatarCacheKey =>
'public_avatars_${BackendIdentity.scope}';
static String get _personaCacheKey =>
'public_personas_${BackendIdentity.scope}';
static Future<void> _cacheCatalog(
String key,
List<Map<String, dynamic>> values,
) async {
await (await SharedPreferences.getInstance()).setString(
key,
jsonEncode(values),
);
}
static Future<List<Map<String, dynamic>>> _cachedCatalog(String key) async {
final value = (await SharedPreferences.getInstance()).getString(key);
if (value == null) return const [];
try {
return (jsonDecode(value) as List).cast<Map<String, dynamic>>().toList();
} catch (_) {
return const [];
}
}
static String? _normalizeName(String? value) {
@@ -52,6 +52,17 @@ class CurrentLedgerStore extends ChangeNotifier {
return _loading ??= _loadCached().whenComplete(() => _loading = null);
}
Future<void> refreshRemote() async {
if (!SessionStore.instance.isAccount ||
SessionStore.instance.shouldUseLocalOnly) {
return;
}
final response = await _dio.get('/api/ledgers');
final values = response.data as List;
LocalDatabase.instance.cacheLedgers(values);
_apply(values);
}
Future<void> _loadCached() async {
_apply(LocalDatabase.instance.ledgers());
}
@@ -78,11 +89,24 @@ class CurrentLedgerStore extends ChangeNotifier {
final items = values
.map((item) => LedgerInfo.fromJson(item as Map<String, dynamic>))
.toList();
_ledgers = items;
_current =
final current =
items.where((item) => item.isDefault).firstOrNull ??
(items.isEmpty ? null : items.first);
notifyListeners();
final changed =
_current?.id != current?.id ||
_ledgers.length != items.length ||
Iterable<int>.generate(items.length).any((index) {
final before = _ledgers[index];
final after = items[index];
return before.id != after.id ||
before.name != after.name ||
before.iconKey != after.iconKey ||
before.isDefault != after.isDefault ||
before.transactionCount != after.transactionCount;
});
_ledgers = items;
_current = current;
if (changed) notifyListeners();
}
Future<void> select(int id) async {
@@ -39,15 +39,17 @@ class GuestMergeService {
: (existingLedger['id'] as num).toInt();
final categoryMap = <int, int>{};
final available = <Map<String, dynamic>>[];
for (final type in ['expense', 'income']) {
for (final type in ['expense', 'income']) {
final response = await _dio.get(
'/api/categories',
queryParameters: {'type': type},
);
available.addAll(
(response.data as List).map((item) => item as Map<String, dynamic>),
);
}
);
}
await _ensureFallbackCategory(available, 'expense');
await _ensureFallbackCategory(available, 'income');
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
final category = value as Map<String, dynamic>;
@@ -90,9 +92,10 @@ class GuestMergeService {
item['name'] == transaction['categoryName'],
);
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
final fallback = available.firstWhere(
(item) => item['type'] == categoryType && item['name'] == '其他',
);
final fallback = available.firstWhere(
(item) => item['type'] == categoryType && item['name'] == '其他',
orElse: () => throw StateError('$categoryType 分类缺少“其他”,无法导入本机账单'),
);
return (fallback['id'] as num).toInt();
}
@@ -145,13 +148,47 @@ class GuestMergeService {
await CurrentLedgerStore.instance.ensureLoaded(force: true);
await CurrentLedgerStore.instance.select(ledgerId);
return GuestMergeResult(
ledgerId: ledgerId,
transactionCount: transactionCount,
);
}
static int? _findMappedDefault(
return GuestMergeResult(
ledgerId: ledgerId,
transactionCount: transactionCount,
);
}
static Future<void> _ensureFallbackCategory(
List<Map<String, dynamic>> available,
String type,
) async {
bool hasFallback() => available.any(
(item) => item['type'] == type && item['name'] == '其他',
);
if (hasFallback()) return;
try {
final response = await _dio.post(
'/api/categories',
data: {
'name': '其他',
'iconKey': 'tag',
'colorKey': type == 'income' ? 'lime' : 'graphite',
'type': type,
},
);
available.add(response.data as Map<String, dynamic>);
} catch (_) {
// Another request may have created it between the list and create calls.
final response = await _dio.get(
'/api/categories',
queryParameters: {'type': type},
);
available.removeWhere((item) => item['type'] == type);
available.addAll(
(response.data as List).map((item) => item as Map<String, dynamic>),
);
if (!hasFallback()) rethrow;
}
}
static int? _findMappedDefault(
List<Map<String, dynamic>> available,
Map<String, dynamic> snapshot,
int oldCategoryId,
@@ -16,12 +16,12 @@ class LocalDatabase {
static const _secureStorage = FlutterSecureStorage();
@visibleForTesting
static LocalDatabase inMemoryForTesting() {
static LocalDatabase inMemoryForTesting({bool seedDefaults = true}) {
final database = LocalDatabase._();
database._database = sqlite3.openInMemory();
database._namespace = 'test';
database._migrate();
database._seedDefaults();
if (seedDefaults) database._seedDefaults();
return database;
}
@@ -69,7 +69,11 @@ class LocalDatabase {
_database = database;
_namespace = namespace;
_migrate();
if (namespace == 'guest') _seedDefaults();
if (namespace == 'guest') {
_seedDefaults();
} else {
ensureDefaultCategories();
}
} catch (_) {
database.close();
rethrow;
@@ -295,6 +299,80 @@ class LocalDatabase {
}
}
/// Ensures an account can still render the category picker and create a
/// manual transaction before its first successful category refresh.
///
/// Account fallback IDs mirror the server's seeded system categories. The
/// remote refresh remains authoritative and will update these rows in place.
void ensureDefaultCategories({String? type}) {
final isGuestNamespace = _namespace == 'guest';
const expense = <(int, String, String, String, String, int)>[
(1, '餐饮', 'food', 'coral', 'expense', 0),
(2, '饮品', 'cup', 'cyan', 'expense', 1),
(3, '购物', 'cart', 'blue', 'expense', 2),
(4, '交通', 'metro', 'teal', 'expense', 3),
(5, '住房', 'house', 'sand', 'expense', 4),
(6, '娱乐', 'game', 'violet', 'expense', 5),
(7, '医疗', 'pill', 'red', 'expense', 6),
(8, '学习', 'book', 'indigo', 'expense', 7),
(9, '服饰', 'shirt', 'plum', 'expense', 8),
(10, '人情', 'gift', 'rose', 'expense', 9),
(11, '旅行', 'plane', 'sky', 'expense', 10),
(12, '其他', 'tag', 'graphite', 'expense', 11),
];
const accountIncome = <(int, String, String, String, String, int)>[
(13, '工资', 'money', 'mint', 'income', 0),
(14, '兼职', 'briefcase', 'forest', 'income', 1),
(15, '理财', 'chart', 'navy', 'income', 2),
(16, '红包', 'gift', 'orange', 'income', 3),
(17, '报销', 'card', 'amber', 'income', 4),
(18, '奖金', 'sparkle', 'aqua', 'income', 5),
(19, '其他', 'tag', 'lime', 'income', 6),
];
const guestIncome = <(int, String, String, String, String, int)>[
(101, '工资', 'money', 'mint', 'income', 10),
(102, '兼职', 'parttime', 'forest', 'income', 20),
(103, '理财', 'invest', 'navy', 'income', 30),
(104, '红包', 'redpacket', 'orange', 'income', 40),
(105, '报销', 'reimburse', 'amber', 'income', 50),
(106, '奖金', 'bonus', 'aqua', 'income', 60),
(107, '其他', 'tag', 'lime', 'income', 70),
];
final catalogs = <String, List<(int, String, String, String, String, int)>>{
'expense': expense,
'income': isGuestNamespace ? guestIncome : accountIncome,
};
final now = DateTime.now().toUtc().toIso8601String();
for (final entry in catalogs.entries) {
if (type != null && entry.key != type) continue;
final active = _db.select(
'''SELECT 1 FROM categories
WHERE type = ? AND is_deleted = 0 AND is_custom = 0 LIMIT 1''',
[entry.key],
);
if (active.isNotEmpty) continue;
for (final item in entry.value) {
_db.execute(
'''
INSERT INTO categories
(id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
icon_key = excluded.icon_key,
color_key = excluded.color_key,
type = excluded.type,
sort_order = excluded.sort_order,
is_custom = 0,
is_deleted = 0,
updated_at = excluded.updated_at
''',
[item.$1, item.$2, item.$3, item.$4, item.$5, item.$6, now],
);
}
}
}
Map<String, dynamic> guestSnapshot() {
final customCategories = _db
.select('''
@@ -49,6 +49,7 @@ class RecognitionDiagnosticDisplay {
'matched' || 'auto_ready' => '已识别',
'confirm' => '待确认',
'started' => '处理中',
'waiting' => '等待结果页',
'failed' => '失败',
_ => '未触发入账',
};
@@ -56,6 +57,7 @@ class RecognitionDiagnosticDisplay {
static String _summaryLabel(String result, String reason) {
if (reason == 'duplicate_result_surface') return '已合并';
if (result == 'waiting') return '等待结果页';
if (_captureFailureReasons.contains(reason)) return '截图失败';
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
@@ -65,6 +67,7 @@ class RecognitionDiagnosticDisplay {
'matched' || 'auto_ready' => '已识别',
'confirm' => '待确认',
'started' => '处理中',
'waiting' => '等待结果页',
'failed' => '失败',
_ => '没事件',
};
@@ -75,12 +78,16 @@ class RecognitionDiagnosticDisplay {
'history_page' => '当前是账单或交易历史页',
'blocked_status' => '当前状态为失败、处理中或已取消',
'no_text' => '截图中没有识别到文字',
'no_success_status' => '没有找到明确或弱完成状态,可开启诊断预览查看脱敏结果',
'payment_input_page' => '当前仍是付款输入或确认页面,已拒绝入账',
'no_success_status' => '暂未找到完成状态,正在按计划复核结果',
'payment_input_page' => '当前仍是付款输入或确认页面,等待结果页,不会提前入账',
'result_page_timeout' => '90 秒内未等到明确结果页,本次流程已停止追踪',
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
'missing_amount' => '成功状态已识别,但没有找到金额',
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
'expected_amount_fallback' => '结果页金额缺失,已使用付款前确认的唯一金额',
'expected_amount_fallback' => '结果页未读到金额,已使用本次付款流程确认的金额',
'weak_expected_fallback' => '结果页未读到金额,付款前金额来源不可靠,请确认后入账',
'result_amount_conflict' => '结果页金额与本次付款金额不一致,请确认后入账',
'amount_source_untrusted' => '金额无法与当前付款流程可靠关联,请确认后入账',
'red_packet_not_settled' => '红包尚未明确到账或退回,不会自动入账',
'duplicate_result_surface' => '同一结果页已处理,本次刷新已忽略',
'weak_status_confirm' => '只识别到弱完成状态,组合证据不足,需确认后入账',
@@ -123,7 +130,7 @@ class RecognitionDiagnosticDisplay {
static String? _amountSourceLabel(String? value) {
return switch (value) {
'expected' => '使用付款金额',
'expected' => '使用本次付款金额',
'result' => '使用结果页金额',
_ => null,
};
@@ -164,13 +171,15 @@ class RecognitionDiagnosticDisplay {
static const _ruleRejectedReasons = {
'history_page',
'blocked_status',
'no_success_status',
'payment_input_page',
'result_page_timeout',
'direction_unknown',
'missing_amount',
'expected_amount_missing',
'red_packet_not_settled',
'weak_status_confirm',
'ambiguous_or_unarmed',
'weak_expected_fallback',
'result_amount_conflict',
'amount_source_untrusted',
};
}
@@ -62,9 +62,47 @@ class RecognitionDiagnostic {
}
}
enum AccessibilityConnectionState {
unauthorized,
reconnecting,
connected,
disconnected;
static AccessibilityConnectionState parse(
Object? value, {
required bool authorized,
required bool connected,
}) {
return switch (value?.toString()) {
'reconnecting' => AccessibilityConnectionState.reconnecting,
'connected' => AccessibilityConnectionState.connected,
'disconnected' => AccessibilityConnectionState.disconnected,
'unauthorized' => AccessibilityConnectionState.unauthorized,
_ =>
!authorized
? unauthorized
: connected
? AccessibilityConnectionState.connected
: disconnected,
};
}
}
class RecognitionStatus {
final bool accessibilityAuthorized;
final bool accessibilityConnected;
final AccessibilityConnectionState accessibilityConnectionState;
final DateTime? accessibilityLastConnectedAt;
final DateTime? accessibilityLastDisconnectedAt;
final DateTime? recognitionProcessStartedAt;
final bool keepAliveExpected;
final bool keepAliveRunning;
final String? keepAliveError;
final bool recentsProtectionExpected;
final bool recentsProtectionActive;
final DateTime? lastRecognitionExitAt;
final String? lastRecognitionExitReason;
final bool taskCleanerRecoveryNeeded;
final bool notificationAuthorized;
final bool notificationConnected;
final bool postNotificationsGranted;
@@ -81,6 +119,19 @@ class RecognitionStatus {
const RecognitionStatus({
required this.accessibilityAuthorized,
required this.accessibilityConnected,
this.accessibilityConnectionState =
AccessibilityConnectionState.unauthorized,
this.accessibilityLastConnectedAt,
this.accessibilityLastDisconnectedAt,
this.recognitionProcessStartedAt,
this.keepAliveExpected = false,
this.keepAliveRunning = false,
this.keepAliveError,
this.recentsProtectionExpected = false,
this.recentsProtectionActive = false,
this.lastRecognitionExitAt,
this.lastRecognitionExitReason,
this.taskCleanerRecoveryNeeded = false,
required this.notificationAuthorized,
required this.notificationConnected,
required this.postNotificationsGranted,
@@ -101,10 +152,39 @@ class RecognitionStatus {
final settings = rawSettings == null || rawSettings.isEmpty
? const <String, dynamic>{}
: jsonDecode(rawSettings) as Map<String, dynamic>;
final accessibilityAuthorized =
value['accessibilityAuthorized'] as bool? ?? false;
final accessibilityConnected =
value['accessibilityConnected'] as bool? ?? false;
DateTime? epochDate(String key) {
final epoch = (value[key] as num?)?.toInt() ?? 0;
return epoch <= 0 ? null : DateTime.fromMillisecondsSinceEpoch(epoch);
}
return RecognitionStatus(
accessibilityAuthorized:
value['accessibilityAuthorized'] as bool? ?? false,
accessibilityConnected: value['accessibilityConnected'] as bool? ?? false,
accessibilityAuthorized: accessibilityAuthorized,
accessibilityConnected: accessibilityConnected,
accessibilityConnectionState: AccessibilityConnectionState.parse(
value['accessibilityConnectionState'],
authorized: accessibilityAuthorized,
connected: accessibilityConnected,
),
accessibilityLastConnectedAt: epochDate('accessibilityLastConnectedAt'),
accessibilityLastDisconnectedAt: epochDate(
'accessibilityLastDisconnectedAt',
),
recognitionProcessStartedAt: epochDate('recognitionProcessStartedAt'),
keepAliveExpected: value['keepAliveExpected'] as bool? ?? false,
keepAliveRunning: value['keepAliveRunning'] as bool? ?? false,
keepAliveError: value['keepAliveError']?.toString(),
recentsProtectionExpected:
value['recentsProtectionExpected'] as bool? ?? false,
recentsProtectionActive:
value['recentsProtectionActive'] as bool? ?? false,
lastRecognitionExitAt: epochDate('lastRecognitionExitAt'),
lastRecognitionExitReason: value['lastRecognitionExitReason']?.toString(),
taskCleanerRecoveryNeeded:
value['taskCleanerRecoveryNeeded'] as bool? ?? false,
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
notificationConnected: value['notificationConnected'] as bool? ?? false,
postNotificationsGranted:
@@ -130,6 +210,21 @@ class RecognitionStatus {
),
);
}
bool get accessibilityNeedsRecovery =>
accessibilityAuthorized &&
!accessibilityConnected &&
accessibilityConnectionState == AccessibilityConnectionState.disconnected;
bool get keepAliveNeedsRecovery => keepAliveExpected && !keepAliveRunning;
String get accessibilityConnectionLabel =>
switch (accessibilityConnectionState) {
AccessibilityConnectionState.unauthorized => '未授权',
AccessibilityConnectionState.reconnecting => '正在重连',
AccessibilityConnectionState.connected => '已连接',
AccessibilityConnectionState.disconnected => '服务未连接',
};
}
class RecognitionCandidate {
@@ -254,6 +349,7 @@ class SpeechEvent {
/// Android native capabilities for screenshots, AI progress and speech.
class ScreenshotChannel {
static const _channel = MethodChannel('com.miaoji/screenshot');
static Future<RecognitionStatus>? _activeAccessibilityConnectionWait;
static void Function(String path)? _screenshotReady;
static void Function(String error)? _screenshotError;
static void Function(SpeechEvent event)? _speechEvent;
@@ -403,6 +499,60 @@ class ScreenshotChannel {
}
}
static Future<RecognitionStatus> waitForAccessibilityConnection({
Duration timeout = const Duration(seconds: 5),
Duration interval = const Duration(milliseconds: 500),
}) {
final active = _activeAccessibilityConnectionWait;
if (active != null) return active;
final operation = _waitForAccessibilityConnection(
timeout: timeout,
interval: interval,
);
_activeAccessibilityConnectionWait = operation;
return operation.whenComplete(() {
if (identical(_activeAccessibilityConnectionWait, operation)) {
_activeAccessibilityConnectionWait = null;
}
});
}
static Future<RecognitionStatus> _waitForAccessibilityConnection({
required Duration timeout,
required Duration interval,
}) async {
var status = await recognitionStatus();
final recognitionEnabled =
status.accessibilityEvents || status.aiScreenshot;
if (!recognitionEnabled ||
!status.accessibilityAuthorized ||
status.accessibilityConnected ||
status.taskCleanerRecoveryNeeded) {
return status;
}
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(interval);
status = await recognitionStatus();
if (!status.accessibilityAuthorized ||
status.accessibilityConnected ||
status.taskCleanerRecoveryNeeded ||
!(status.accessibilityEvents || status.aiScreenshot)) {
return status;
}
}
return status;
}
static Future<bool> ensureRecognitionKeepAlive() async {
try {
return await _channel.invokeMethod<bool>('ensureRecognitionKeepAlive') ??
false;
} on MissingPluginException {
return false;
}
}
static Future<bool> clearRecognitionDiagnostic() async {
try {
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
+17 -10
View File
@@ -6,12 +6,13 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
enum AiAccessState { guest, reauthenticate, cloudDisabled }
enum AiAccessState { guest, reauthenticate, aiDisabled, cloudDisabled }
AiAccessState? currentAiAccessState() {
final session = SessionStore.instance;
if (session.isGuest) return AiAccessState.guest;
if (session.needsReauth) return AiAccessState.reauthenticate;
if (!session.aiEnabled) return AiAccessState.aiDisabled;
if (!session.cloudSyncEnabled) return AiAccessState.cloudDisabled;
return null;
}
@@ -32,23 +33,27 @@ class _AiAccessGateState extends State<AiAccessGate> {
String get _title => switch (widget.state) {
AiAccessState.guest => '登录后使用 AI 助手',
AiAccessState.reauthenticate => '登录状态已过期',
AiAccessState.aiDisabled => 'AI 功能已关闭',
AiAccessState.cloudDisabled => 'AI 功能需要云连接',
};
String get _message => switch (widget.state) {
AiAccessState.guest => '游客账单会继续安全保存在本机。登录后即可使用 AI 聊天、语音解析和图片识别。',
AiAccessState.reauthenticate => '本地记账不受影响。重新登录后可以继续使用 AI 和云同步。',
AiAccessState.aiDisabled => '当前账号暂未开通 AI,手动记账和统计不受影响。',
AiAccessState.cloudDisabled => '当前账号仅使用本地数据。开启云同步后才能发送 AI 消息。',
};
String get _actionLabel => switch (widget.state) {
String? get _actionLabel => switch (widget.state) {
AiAccessState.guest => '登录后使用',
AiAccessState.reauthenticate => '重新登录',
AiAccessState.aiDisabled => null,
AiAccessState.cloudDisabled => '开启云同步',
};
Future<void> _act() async {
if (_busy) return;
if (widget.state == AiAccessState.aiDisabled) return;
if (widget.onAction != null) {
await widget.onAction!();
return;
@@ -115,15 +120,17 @@ class _AiAccessGateState extends State<AiAccessGate> {
height: 1.6,
),
),
SizedBox(height: 22),
SizedBox(
width: double.infinity,
child: JzActionButton(
label: _actionLabel,
loading: _busy,
onPressed: _busy ? null : _act,
if (_actionLabel case final label?) ...[
SizedBox(height: 22),
SizedBox(
width: double.infinity,
child: JzActionButton(
label: label,
loading: _busy,
onPressed: _busy ? null : _act,
),
),
),
],
if (widget.state == AiAccessState.guest) ...[
SizedBox(height: 12),
Text(
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
class BackendStatusIcon extends StatelessWidget {
final Future<void> Function() onRetry;
const BackendStatusIcon({super.key, required this.onRetry});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<BackendAvailability>(
valueListenable: ApiClient.availability,
builder: (context, availability, _) {
if (availability != BackendAvailability.offline) {
return const SizedBox.shrink();
}
return IconButton(
tooltip: '当前显示本地数据,点击重试',
onPressed: onRetry,
icon: AppIcons.icon(
AppIcons.offline,
size: 18,
color: AppTheme.orange,
),
);
},
);
}
}
+50
View File
@@ -5,6 +5,7 @@ import 'package:miaoji_zhang/shared/api/backend_identity.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
void main() {
@@ -176,6 +177,55 @@ void main() {
expect(report.balance, 2400);
});
test('无障碍连接状态区分重连、正常和异常', () {
final reconnecting = RecognitionStatus.fromMap({
'accessibilityAuthorized': true,
'accessibilityConnected': false,
'accessibilityConnectionState': 'reconnecting',
'accessibilityLastConnectedAt': 1724472000000,
'keepAliveExpected': true,
'keepAliveRunning': false,
'keepAliveError': '系统限制了后台保护启动',
'recentsProtectionExpected': true,
'recentsProtectionActive': true,
'lastRecognitionExitAt': 1788077518000,
'lastRecognitionExitReason': 'low_memory: single-cleaner',
'taskCleanerRecoveryNeeded': true,
'settings': jsonEncode({'accessibilityEvents': true}),
});
final connected = RecognitionStatus.fromMap({
'accessibilityAuthorized': true,
'accessibilityConnected': true,
'accessibilityConnectionState': 'connected',
'settings': '{}',
});
final disconnected = RecognitionStatus.fromMap({
'accessibilityAuthorized': true,
'accessibilityConnected': false,
'accessibilityConnectionState': 'disconnected',
'settings': '{}',
});
expect(
reconnecting.accessibilityConnectionState,
AccessibilityConnectionState.reconnecting,
);
expect(reconnecting.accessibilityConnectionLabel, '正在重连');
expect(reconnecting.accessibilityLastConnectedAt, isNotNull);
expect(reconnecting.keepAliveExpected, isTrue);
expect(reconnecting.keepAliveRunning, isFalse);
expect(reconnecting.keepAliveNeedsRecovery, isTrue);
expect(reconnecting.keepAliveError, '系统限制了后台保护启动');
expect(reconnecting.recentsProtectionExpected, isTrue);
expect(reconnecting.recentsProtectionActive, isTrue);
expect(reconnecting.lastRecognitionExitAt, isNotNull);
expect(reconnecting.lastRecognitionExitReason, contains('single-cleaner'));
expect(reconnecting.taskCleanerRecoveryNeeded, isTrue);
expect(connected.accessibilityConnectionLabel, '已连接');
expect(disconnected.accessibilityConnectionLabel, '服务未连接');
expect(disconnected.accessibilityNeedsRecovery, isTrue);
});
test('分类图标目录至少 32 个且键值不重复', () {
final keys = AppIcons.categoryCatalog.map((item) => item.key).toList();
expect(keys.length, greaterThanOrEqualTo(32));
+177
View File
@@ -0,0 +1,177 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.dart';
import 'package:miaoji_zhang/shared/services/local_database.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
void main() {
tearDown(() {
ApiClient.availability.value = BackendAvailability.unknown;
});
testWidgets('离线状态只显示可点击且可访问的云朵重试图标', (tester) async {
var retries = 0;
ApiClient.availability.value = BackendAvailability.offline;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(
actions: [
BackendStatusIcon(
onRetry: () async {
retries++;
},
),
],
),
),
),
);
final retry = find.byTooltip('当前显示本地数据,点击重试');
expect(retry, findsOneWidget);
await tester.tap(retry);
await tester.pump();
expect(retries, 1);
ApiClient.availability.value = BackendAvailability.online;
await tester.pump();
expect(retry, findsNothing);
});
test('连接、发送和接收失败都会被识别为后端不可达', () {
DioException failure(DioExceptionType type) => DioException(
requestOptions: RequestOptions(path: '/test'),
type: type,
);
expect(
isConnectivityError(failure(DioExceptionType.connectionError)),
isTrue,
);
expect(
isConnectivityError(failure(DioExceptionType.connectionTimeout)),
isTrue,
);
expect(isConnectivityError(failure(DioExceptionType.sendTimeout)), isTrue);
expect(
isConnectivityError(failure(DioExceptionType.receiveTimeout)),
isTrue,
);
expect(isConnectivityError(failure(DioExceptionType.badResponse)), isFalse);
});
test('AI 伙伴缓存 DTO 可无损往返', () {
final source = {
'avatarKey': 'cat',
'personaKey': 'gentle',
'customName': '小记',
'roastLevel': 20,
'stickerFrequency': 40,
'proactiveLevel': 60,
};
final companion = AiCompanion.fromJson(source);
expect(companion.toJson(), source);
});
test('账号分类缓存为空时会补齐支出和收入兜底并允许本地记账', () {
final database = LocalDatabase.inMemoryForTesting(seedDefaults: false);
addTearDown(database.close);
database.ensureDefaultCategories();
final expense = database.categories('expense');
final income = database.categories('income');
expect(expense, hasLength(12));
expect(income, hasLength(7));
expect(expense.first['name'], '餐饮');
expect(income.first['name'], '工资');
expect(income.first['id'], 13);
final transaction = database.createTransaction({
'ledgerId': 1,
'categoryId': expense.first['id'],
'type': 'expense',
'amount': 18.5,
'occurredAt': DateTime.utc(2026, 8, 21).toIso8601String(),
});
expect(transaction['amount'], 18.5);
expect(transaction['categoryName'], '餐饮');
});
test('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () {
final contracts = <String, List<String>>{
'lib/features/home/pages/home_page.dart': [
'monthLocal',
'monthRemote',
'BudgetApi.getLocal',
'BudgetApi.getRemote',
'refreshRemote',
],
'lib/features/add/add_page.dart': [
"categoriesLocal('expense')",
"categoriesRemote('expense')",
"categoriesRemote('income')",
'localFirst: true',
],
'lib/features/stats/stats_page.dart': [
'periodStatsLocal',
'periodStatsRemote',
],
'lib/features/stats/report_page.dart': [
'ReportApi.local',
'ReportApi.remote',
],
'lib/features/settings/category_manage_page.dart': [
'categoriesLocal',
'categoriesRemote',
'localFirst: true',
],
'lib/features/settings/recycle_bin_page.dart': [
'recycleBinLocal',
'recycleBinRemote',
],
'lib/features/settings/companion_page.dart': [
'cachedCompanion',
'cachedAvatars',
'cachedPersonas',
'forceRemote: true',
],
};
for (final entry in contracts.entries) {
final source = File(entry.key).readAsStringSync();
expect(source, contains('_loadRevision'), reason: entry.key);
expect(source, contains('BackendStatusIcon'), reason: entry.key);
for (final token in entry.value) {
expect(source, contains(token), reason: '${entry.key}: $token');
}
}
final companion = File(
'lib/features/settings/companion_page.dart',
).readAsStringSync();
expect(companion, isNot(contains('if (!_loaded)')));
expect(companion, contains('BackendAvailability.online'));
});
test('AI 缓存按后端和账号隔离,回收站破坏性操作只允许在线执行', () {
final auth = File('lib/shared/api/auth_api.dart').readAsStringSync();
final config = File('lib/shared/api/config_api.dart').readAsStringSync();
final recycle = File(
'lib/features/settings/recycle_bin_page.dart',
).readAsStringSync();
expect(auth, contains("'ai_companion_\${BackendIdentity.scope}_\$userId'"));
expect(config, contains("'public_avatars_\${BackendIdentity.scope}'"));
expect(config, contains("'public_personas_\${BackendIdentity.scope}'"));
expect(recycle, contains('availability != BackendAvailability.online'));
expect(recycle, contains('if (online)'));
});
}
+26 -2
View File
@@ -153,9 +153,21 @@ void main() {
);
expect(
RecognitionDiagnosticDisplay.from(
diagnostic('rejected', 'payment_input_page'),
diagnostic('waiting', 'payment_input_page'),
).summaryLabel,
'规则拒绝',
'等待结果页',
);
expect(
RecognitionDiagnosticDisplay.from(
diagnostic('waiting', 'payment_input_page'),
).reasonLabel,
'当前仍是付款输入或确认页面,等待结果页,不会提前入账',
);
expect(
RecognitionDiagnosticDisplay.from(
diagnostic('rejected', 'result_page_timeout'),
).reasonLabel,
'90 秒内未等到明确结果页,本次流程已停止追踪',
);
expect(
RecognitionDiagnosticDisplay.from(
@@ -163,5 +175,17 @@ void main() {
).summaryLabel,
'已合并',
);
expect(
RecognitionDiagnosticDisplay.from(
diagnostic('confirm', 'expected_amount_fallback'),
).reasonLabel,
'结果页未读到金额,已使用本次付款流程确认的金额',
);
expect(
RecognitionDiagnosticDisplay.from(
diagnostic('confirm', 'result_amount_conflict'),
).reasonLabel,
'结果页金额与本次付款金额不一致,请确认后入账',
);
});
}
+127
View File
@@ -68,6 +68,9 @@ void main() {
final companion = File(
'lib/features/settings/companion_page.dart',
).readAsStringSync();
final onboarding = File(
'lib/features/onboarding/pages/onboarding_page.dart',
).readAsStringSync();
final me = File('lib/features/settings/me_page.dart').readAsStringSync();
final report = File(
'lib/features/stats/report_page.dart',
@@ -80,10 +83,27 @@ void main() {
companion,
isNot(contains('context.jz.aiBackground : Colors.white')),
);
expect(onboarding, contains(': context.jz.card'));
expect(
onboarding,
isNot(contains('context.jz.aiBackground : Colors.white')),
);
expect(me, isNot(contains('context.jz.primaryBackground : Colors.white')));
expect(report, isNot(contains('selected ? Colors.white')));
});
test('首次引导只提交服务端实际返回的 AI 伙伴配置', () {
final onboarding = File(
'lib/features/onboarding/pages/onboarding_page.dart',
).readAsStringSync();
expect(onboarding, contains('_hasValidCatalogSelection'));
expect(onboarding, contains('AI 伙伴配置暂不可用'));
expect(onboarding, contains('重新加载'));
expect(onboarding, isNot(contains('_fallbackAvatars')));
expect(onboarding, isNot(contains('_fallbackPersonas')));
});
test('聊天附件只保留拍照和相册导入', () {
final source = File('lib/features/chat/chat_page.dart').readAsStringSync();
@@ -94,6 +114,49 @@ void main() {
expect(source, isNot(contains('ScreenshotChannel.capture')));
});
test('底部导航固定保留 AI 入口且关闭状态在聊天二级页处理', () {
final shell = File(
'lib/features/home/pages/main_shell.dart',
).readAsStringSync();
final router = File('lib/app/app.dart').readAsStringSync();
final gate = File(
'lib/shared/widgets/ai_access_gate.dart',
).readAsStringSync();
expect(shell, isNot(contains('showAiEntry')));
expect(shell, contains('_tab(companion.name, AppIcons.chat, 2)'));
expect(router, isNot(contains("state.matchedLocation == '/chat'")));
expect(gate, contains('AiAccessState.aiDisabled'));
expect(gate, contains('AI 功能已关闭'));
expect(gate, contains('手动记账和统计不受影响'));
});
test('报告入口归入统计并继承当前周期', () {
final stats = File('lib/features/stats/stats_page.dart').readAsStringSync();
final report = File(
'lib/features/stats/report_page.dart',
).readAsStringSync();
final me = File('lib/features/settings/me_page.dart').readAsStringSync();
expect(me, isNot(contains("context.push('/report')")));
expect(stats, contains("path: '/report'"));
expect(stats, contains("'period': _period"));
expect(stats, contains("'date': _anchor.toIso8601String()"));
expect(stats, contains('查看完整报告'));
expect(report, contains('widget.initialPeriod'));
expect(report, contains('widget.initialAnchor'));
});
test('聊天输入为空显示附件加号,有文本时显示发送按钮', () {
final chat = File('lib/features/chat/chat_page.dart').readAsStringSync();
expect(chat, contains("ValueKey('add-attachment')"));
expect(chat, contains("ValueKey('send-message')"));
expect(chat, contains('child: hasText'));
expect(chat, contains("tooltip: '添加附件'"));
expect(chat, isNot(contains("'附件'")));
});
test('表情包具备离线缓存、内置兜底和展开刷新', () {
final api = File('lib/shared/api/business_api.dart').readAsStringSync();
final chat = File('lib/features/chat/chat_page.dart').readAsStringSync();
@@ -216,4 +279,68 @@ void main() {
expect(source, contains('setRecognitionToggle(key, false)'));
expect(source, contains('_BackgroundKeepAliveCard'));
});
test('长期识别组件使用独立保活进程并支持非阻塞重连', () {
final manifest = File(
'android/app/src/main/AndroidManifest.xml',
).readAsStringSync();
final channel = File(
'lib/shared/services/screenshot_channel.dart',
).readAsStringSync();
final activity = File(
'android/app/src/main/kotlin/com/nx/miaoji/MainActivity.kt',
).readAsStringSync();
final settingsPage = File(
'lib/features/settings/screenshot_settings_page.dart',
).readAsStringSync();
final keepAlive = File(
'android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveService.kt',
).readAsStringSync();
for (final component in [
'ScreenshotTileService',
'PaymentNotificationListenerService',
'ScreenshotAccessibilityService',
]) {
final declaration = RegExp(
'<service\\s+android:name="\\.$component"[\\s\\S]*?</service>',
).firstMatch(manifest)?.group(0);
expect(declaration, isNotNull);
expect(declaration, contains('android:stopWithTask="false"'));
expect(declaration, contains('android:process=":recognition"'));
}
final provider = RegExp(
'<provider\\s+android:name="\\.RecognitionBridgeProvider"[\\s\\S]*?/>',
).firstMatch(manifest)?.group(0);
expect(provider, isNotNull);
expect(provider, contains('android:process=":recognition"'));
expect(manifest, contains('android:name=".OneShotProjectionService"'));
final projection = RegExp(
'<service\\s+android:name="\\.OneShotProjectionService"[\\s\\S]*?/>',
).firstMatch(manifest)?.group(0);
expect(projection, isNotNull);
expect(projection, contains('android:process=":projection"'));
expect(manifest, contains('android:name=".RecognitionKeepAliveService"'));
expect(manifest, contains('android:foregroundServiceType="specialUse"'));
expect(manifest, contains('FOREGROUND_SERVICE_SPECIAL_USE'));
expect(manifest, contains('PROPERTY_SPECIAL_USE_FGS_SUBTYPE'));
expect(keepAlive, contains('START_STICKY'));
expect(keepAlive, contains('智能识别运行中'));
expect(channel, contains('waitForAccessibilityConnection'));
expect(channel, contains('_activeAccessibilityConnectionWait'));
expect(channel, contains('ensureRecognitionKeepAlive'));
expect(channel, contains('AccessibilityConnectionState.reconnecting'));
expect(activity, contains('"reconnecting"'));
expect(activity, contains('"disconnected"'));
expect(activity, contains('setExcludeFromRecents'));
expect(activity, contains('getHistoricalProcessExitReasons'));
expect(activity, contains('single-cleaner'));
expect(settingsPage, contains('_check();'));
expect(settingsPage, isNot(contains('_check(waitForConnection: true)')));
expect(settingsPage, contains('系统仍显示已授权,但 OriginOS 未重新绑定服务'));
expect(settingsPage, contains('启动后台保护'));
expect(settingsPage, contains('OriginOS 清理了识别进程'));
expect(settingsPage, contains('记之已从最近任务隐藏'));
expect(channel, contains('taskCleanerRecoveryNeeded'));
});
}