Compare commits
27
Commits
ff55623d80
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477f83c5b1 | ||
|
|
9ccbdfead4 | ||
|
|
c6f98b5445 | ||
|
|
acf6356ba6 | ||
|
|
f8f0b3c4fd | ||
|
|
78c42eff53 | ||
|
|
716c2917d4 | ||
|
|
dc993a09d6 | ||
|
|
6d2e9c884c | ||
|
|
011ef82cd0 | ||
|
|
5fbf159369 | ||
|
|
cceb8a6ae4 | ||
|
|
af5ca8be73 | ||
|
|
b1ff485bda | ||
|
|
414e245fb4 | ||
|
|
85fa02f142 | ||
|
|
b15fed5b6a | ||
|
|
613a278505 | ||
|
|
294a7de61d | ||
|
|
979684a06e | ||
|
|
9bb45aac83 | ||
|
|
5f4a1d40ed | ||
|
|
5215ea2ea8 | ||
|
|
a1433f1f4e | ||
|
|
9a8c23e2f0 | ||
|
|
9f87914235 | ||
|
|
ef35dc748a |
@@ -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
|
||||||
@@ -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
+62
-7
@@ -34,8 +34,10 @@ pipeline {
|
|||||||
https_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,.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'
|
||||||
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"
|
// Keep dependency caches outside cleanWs. The build node already owns
|
||||||
PUB_CACHE = "${WORKSPACE}/.ci/pub-cache"
|
// 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_CREDENTIALS = 'openlist_key'
|
||||||
OPENLIST_BASE_URL = 'https://openlist.nxsir.cn'
|
OPENLIST_BASE_URL = 'https://openlist.nxsir.cn'
|
||||||
OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/jizhang'
|
OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/jizhang'
|
||||||
@@ -57,7 +59,10 @@ pipeline {
|
|||||||
if (versionParts.size() != 2) { error("Invalid pubspec version: ${env.PUBSPEC_VERSION}") }
|
if (versionParts.size() != 2) { error("Invalid pubspec version: ${env.PUBSPEC_VERSION}") }
|
||||||
env.APP_VERSION = versionParts[0]
|
env.APP_VERSION = versionParts[0]
|
||||||
env.BASE_VERSION_CODE = versionParts[1]
|
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.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.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"
|
env.APK_BASENAME = "JiZhi-${env.IMMUTABLE_TAG}-vc${env.ANDROID_VERSION_CODE}-arm64-v8a.apk"
|
||||||
@@ -95,6 +100,55 @@ PY
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
"$FLUTTER_ROOT/bin/flutter" config --no-analytics
|
"$FLUTTER_ROOT/bin/flutter" config --no-analytics
|
||||||
"$FLUTTER_ROOT/bin/flutter" pub get
|
"$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
|
# Keep warnings/errors fatal while allowing the repository's existing
|
||||||
# informational style lints to be cleaned up independently.
|
# informational style lints to be cleaned up independently.
|
||||||
"$FLUTTER_ROOT/bin/flutter" analyze --no-pub --no-fatal-infos
|
"$FLUTTER_ROOT/bin/flutter" analyze --no-pub --no-fatal-infos
|
||||||
@@ -113,12 +167,13 @@ PY
|
|||||||
sh '''
|
sh '''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
"$FLUTTER_ROOT/bin/flutter" build apk \
|
"$FLUTTER_ROOT/bin/flutter" build apk \
|
||||||
--flavor internal --release --target-platform android-arm64 \
|
--flavor internal --release --target-platform android-arm64 --split-per-abi \
|
||||||
--build-name "$APP_VERSION" --build-number "$ANDROID_VERSION_CODE" \
|
--build-name "$APP_VERSION" --build-number "$FLUTTER_BUILD_NUMBER" \
|
||||||
--dart-define=INTERNAL_BUILD=true \
|
--dart-define=INTERNAL_BUILD=true \
|
||||||
--dart-define="API_BASE_URL=$API_BASE_URL" \
|
--dart-define="API_BASE_URL=$API_BASE_URL" \
|
||||||
--dart-define="APP_VERSION=$IMMUTABLE_TAG"
|
--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"
|
test -s "$source_apk"
|
||||||
cp "$source_apk" "$APK_PATH"
|
cp "$source_apk" "$APK_PATH"
|
||||||
'''
|
'''
|
||||||
@@ -172,7 +227,7 @@ PY
|
|||||||
|
|
||||||
post {
|
post {
|
||||||
success { archiveArtifacts artifacts: 'artifacts/*.apk,artifacts/*.sha256,artifacts/*.json', fingerprint: true }
|
success { archiveArtifacts artifacts: 'artifacts/*.apk,artifacts/*.sha256,artifacts/*.json', fingerprint: true }
|
||||||
always {
|
cleanup {
|
||||||
script {
|
script {
|
||||||
// A build can be aborted while still waiting for an executor. In that
|
// A build can be aborted while still waiting for an executor. In that
|
||||||
// case Jenkins has no FilePath context and cleanWs would mask the
|
// case Jenkins has no FilePath context and cleanWs would mask the
|
||||||
|
|||||||
@@ -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
@@ -5,7 +5,7 @@ import { adminAuth } from './auth'
|
|||||||
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
|
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
|
||||||
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined,
|
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined,
|
||||||
NotificationOutlined, SafetyCertificateOutlined, AuditOutlined,
|
NotificationOutlined, SafetyCertificateOutlined, AuditOutlined,
|
||||||
LogoutOutlined } from '@ant-design/icons-vue'
|
LogoutOutlined, CloudServerOutlined, FundOutlined } from '@ant-design/icons-vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -14,20 +14,29 @@ const selectedKeys = ref<string[]>([String(route.name)])
|
|||||||
|
|
||||||
watch(() => route.name, (n) => { selectedKeys.value = [String(n)] })
|
watch(() => route.name, (n) => { selectedKeys.value = [String(n)] })
|
||||||
|
|
||||||
const nav = computed(() => [
|
const navGroups = computed(() => [
|
||||||
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
|
{ label: '概览', items: [
|
||||||
{ key: 'Settings', icon: ControlOutlined, label: '系统设置' },
|
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
|
||||||
{ key: 'Configs', icon: SettingOutlined, label: '品牌配置' },
|
] },
|
||||||
{ key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' },
|
{ label: 'AI 配置', items: [
|
||||||
{ key: 'Personas', icon: SmileOutlined, label: 'AI 性格' },
|
{ key: 'ModelService', icon: CloudServerOutlined, label: '模型服务' },
|
||||||
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
|
{ key: 'Personas', icon: SmileOutlined, label: 'AI 性格' },
|
||||||
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
|
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
|
||||||
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
|
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
|
||||||
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
|
] },
|
||||||
...(adminAuth.identity.value?.role === 'super_admin' ? [
|
{ 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: 'AdminAccounts', icon: SafetyCertificateOutlined, label: '管理员账号' },
|
||||||
{ key: 'Audit', icon: AuditOutlined, label: '操作审计' },
|
{ key: 'Audit', icon: AuditOutlined, label: '操作审计' },
|
||||||
] : []),
|
] }] : []),
|
||||||
])
|
])
|
||||||
|
|
||||||
watch(adminAuth.identity, value => {
|
watch(adminAuth.identity, value => {
|
||||||
@@ -43,20 +52,23 @@ async function logout() {
|
|||||||
<template>
|
<template>
|
||||||
<router-view v-if="route.meta.public || route.name === 'ChangePassword'" />
|
<router-view v-if="route.meta.public || route.name === 'ChangePassword'" />
|
||||||
<a-layout v-else style="min-height: 100vh">
|
<a-layout v-else style="min-height: 100vh">
|
||||||
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="200"
|
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="224"
|
||||||
style="border-right: 1px solid #f0f0f0">
|
breakpoint="lg" class="app-sider">
|
||||||
<div style="padding: 18px 20px; font-size: 16px; font-weight: 700; white-space: nowrap; overflow: hidden;">
|
<div class="brand-lockup">
|
||||||
<span style="color:#25211E;margin-right:6px">✎</span>记之 Admin
|
<FundOutlined />
|
||||||
|
<span>记之 Admin</span>
|
||||||
</div>
|
</div>
|
||||||
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" :style="{ borderRight: 0 }"
|
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" :style="{ borderRight: 0 }"
|
||||||
@click="({key}: {key: string}) => router.push({name: key})">
|
@click="({key}: {key: string}) => router.push({name: key})">
|
||||||
<a-menu-item v-for="n in nav" :key="n.key">
|
<a-menu-item-group v-for="group in navGroups" :key="group.label" :title="group.label">
|
||||||
<component :is="n.icon" />
|
<a-menu-item v-for="item in group.items" :key="item.key">
|
||||||
<span>{{ n.label }}</span>
|
<component :is="item.icon" />
|
||||||
</a-menu-item>
|
<span>{{ item.label }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu-item-group>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
<div style="position:absolute;bottom:16px;left:16px;right:16px">
|
<div class="logout-area">
|
||||||
<a-button type="text" block style="text-align:left" @click="logout">
|
<a-button type="text" block @click="logout">
|
||||||
<template #icon><LogoutOutlined /></template>退出登录
|
<template #icon><LogoutOutlined /></template>退出登录
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,9 +80,24 @@ async function logout() {
|
|||||||
<a-tag>{{ adminAuth.identity.value?.role }}</a-tag>
|
<a-tag>{{ adminAuth.identity.value?.role }}</a-tag>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-layout-header>
|
</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 />
|
<router-view />
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -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),
|
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),
|
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}`),
|
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),
|
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),
|
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),
|
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
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@ const router = createRouter({
|
|||||||
{ path: '/change-password', name: 'ChangePassword', component: () => import('../views/ChangePassword.vue') },
|
{ path: '/change-password', name: 'ChangePassword', component: () => import('../views/ChangePassword.vue') },
|
||||||
{ path: '/', redirect: '/dashboard' },
|
{ path: '/', redirect: '/dashboard' },
|
||||||
{ path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
|
{ path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
|
||||||
{ path: '/settings', name: 'Settings', component: () => import('../views/Settings.vue') },
|
{ path: '/settings', redirect: '/ai/model' },
|
||||||
{ path: '/configs', name: 'Configs', component: () => import('../views/Configs.vue') },
|
{ 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: '/categories', name: 'SysCategories', component: () => import('../views/SysCategories.vue') },
|
||||||
{ path: '/personas', name: 'Personas', component: () => import('../views/Personas.vue') },
|
{ path: '/personas', name: 'Personas', component: () => import('../views/Personas.vue') },
|
||||||
{ path: '/avatars', name: 'Avatars', component: () => import('../views/Avatars.vue') },
|
{ path: '/avatars', name: 'Avatars', component: () => import('../views/Avatars.vue') },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createAccount() {
|
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)
|
await api.createAdminAccount(form)
|
||||||
message.success('管理员已创建')
|
message.success('管理员已创建')
|
||||||
createOpen.value = false
|
createOpen.value = false
|
||||||
@@ -50,7 +50,9 @@ async function update(account: Account, patch: Partial<Account>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitReset() {
|
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)
|
await api.resetAdminPassword(resetTarget.value.id, resetPassword.value)
|
||||||
message.success('密码已重置,现有会话已撤销')
|
message.success('密码已重置,现有会话已撤销')
|
||||||
resetTarget.value = null
|
resetTarget.value = null
|
||||||
@@ -110,7 +112,7 @@ onMounted(load)
|
|||||||
<a-modal v-model:open="createOpen" title="新建管理员" ok-text="创建" @ok="createAccount">
|
<a-modal v-model:open="createOpen" title="新建管理员" ok-text="创建" @ok="createAccount">
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-form-item label="用户名"><a-input v-model:value="form.username" /></a-form-item>
|
<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-form-item label="角色">
|
||||||
<a-select v-model:value="form.role">
|
<a-select v-model:value="form.role">
|
||||||
<a-select-option value="operator">operator</a-select-option>
|
<a-select-option value="operator">operator</a-select-option>
|
||||||
@@ -123,7 +125,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<a-modal :open="!!resetTarget" title="重置密码" ok-text="重置并撤销会话"
|
<a-modal :open="!!resetTarget" title="重置密码" ok-text="重置并撤销会话"
|
||||||
@cancel="resetTarget = null" @ok="submitReset">
|
@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>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ const form = reactive({ currentPassword: '', newPassword: '', confirmPassword: '
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
async function submit() {
|
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('两次输入的新密码不一致')
|
if (form.newPassword !== form.confirmPassword) return message.error('两次输入的新密码不一致')
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -39,14 +43,19 @@ async function logout() {
|
|||||||
<a-input-password v-model:value="form.currentPassword" autocomplete="current-password" />
|
<a-input-password v-model:value="form.currentPassword" autocomplete="current-password" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="新密码" required>
|
<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>
|
||||||
<a-form-item label="确认新密码" required>
|
<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-form-item>
|
||||||
<a-space style="width:100%;justify-content:flex-end">
|
<a-space style="width:100%;justify-content:flex-end">
|
||||||
<a-button @click="logout">退出登录</a-button>
|
<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-space>
|
||||||
</a-form>
|
</a-form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -10,11 +10,16 @@ const loading = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
async function submit() {
|
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
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const user = await adminAuth.login(form.username, form.password)
|
const user = await adminAuth.login(username, form.password)
|
||||||
if (user.mustChangePassword) {
|
if (user.mustChangePassword) {
|
||||||
await router.replace('/change-password')
|
await router.replace('/change-password')
|
||||||
} else {
|
} else {
|
||||||
@@ -40,9 +45,14 @@ async function submit() {
|
|||||||
<a-input v-model:value="form.username" autocomplete="username" size="large" autofocus />
|
<a-input v-model:value="form.username" autocomplete="username" size="large" autofocus />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="密码" required>
|
<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-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>
|
</a-form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -4,6 +4,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using MiaoJiZhang.Api.Controllers;
|
||||||
using MiaoJiZhang.Api.Services;
|
using MiaoJiZhang.Api.Services;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
@@ -130,6 +131,30 @@ public sealed class ApiFixture : IAsyncLifetime
|
|||||||
[Collection(ApiCollection.Name)]
|
[Collection(ApiCollection.Name)]
|
||||||
public sealed class ApiIntegrationTests(ApiFixture fixture)
|
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]
|
[Fact]
|
||||||
public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether()
|
public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether()
|
||||||
{
|
{
|
||||||
@@ -663,8 +688,12 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
|
|||||||
using var superAdmin = await fixture.AdminAsync();
|
using var superAdmin = await fixture.AdminAsync();
|
||||||
var suffix = Guid.NewGuid().ToString("N")[..10];
|
var suffix = Guid.NewGuid().ToString("N")[..10];
|
||||||
var username = $"viewer_{suffix}";
|
var username = $"viewer_{suffix}";
|
||||||
const string initialPassword = "viewer-initial-password-123";
|
const string initialPassword = "init06";
|
||||||
const string permanentPassword = "viewer-permanent-password-456";
|
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(
|
var createdResponse = await superAdmin.PostAsJsonAsync(
|
||||||
"/api/admin/security/accounts",
|
"/api/admin/security/accounts",
|
||||||
new { username, password = initialPassword, role = "viewer" });
|
new { username, password = initialPassword, role = "viewer" });
|
||||||
@@ -860,6 +889,29 @@ public sealed class RecognitionBatchActionParserTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
public sealed class BudgetRecommendationValidationTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -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)
|
public async Task<IActionResult> Create(CreateAdminUserRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var username = request.Username.Trim();
|
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))
|
!AdminRoles.All.Contains(request.Role))
|
||||||
return BadRequest(new ApiError("ADMIN_ACCOUNT_INVALID", "管理员账号、密码或角色无效"));
|
return BadRequest(new ApiError("ADMIN_ACCOUNT_INVALID", "管理员账号、密码或角色无效"));
|
||||||
if (await db.AdminUsers.AnyAsync(item => item.Username == username, ct))
|
if (await db.AdminUsers.AnyAsync(item => item.Username == username, ct))
|
||||||
@@ -90,8 +91,8 @@ public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
|
|||||||
ResetAdminPasswordRequest request,
|
ResetAdminPasswordRequest request,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (request.Password.Length is < 12 or > 128)
|
if (request.Password.Length is < AdminPasswordPolicy.MinLength or > AdminPasswordPolicy.MaxLength)
|
||||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 12 到 128 位"));
|
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 6 到 128 位"));
|
||||||
var user = await db.AdminUsers.FindAsync([id], ct);
|
var user = await db.AdminUsers.FindAsync([id], ct);
|
||||||
if (user is null) return NotFound();
|
if (user is null) return NotFound();
|
||||||
user.PasswordHash = AdminSessionService.HashPassword(request.Password);
|
user.PasswordHash = AdminSessionService.HashPassword(request.Password);
|
||||||
|
|||||||
@@ -53,8 +53,9 @@ public sealed class AdminAuthController(
|
|||||||
AdminChangePasswordRequest request,
|
AdminChangePasswordRequest request,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (request.NewPassword.Length < 12 || request.NewPassword.Length > 128)
|
if (request.NewPassword.Length < AdminPasswordPolicy.MinLength ||
|
||||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 12 到 128 位"));
|
request.NewPassword.Length > AdminPasswordPolicy.MaxLength)
|
||||||
|
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 6 到 128 位"));
|
||||||
var principal = AdminRequestContext.Principal(HttpContext)!;
|
var principal = AdminRequestContext.Principal(HttpContext)!;
|
||||||
var user = await db.AdminUsers.FirstAsync(item => item.Id == principal.UserId, ct);
|
var user = await db.AdminUsers.FirstAsync(item => item.Id == principal.UserId, ct);
|
||||||
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
|
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ namespace MiaoJiZhang.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[AdminAuth]
|
[AdminAuth]
|
||||||
[Route("api/admin")]
|
[Route("api/admin")]
|
||||||
public class AdminController(AppDbContext db) : ControllerBase
|
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) }); }
|
[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) }); }
|
||||||
|
|
||||||
@@ -40,6 +43,8 @@ public class AdminController(AppDbContext db) : ControllerBase
|
|||||||
cfg.Version++;
|
cfg.Version++;
|
||||||
cfg.UpdatedAt = DateTime.UtcNow;
|
cfg.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
|
||||||
|
llmClient.InvalidateConfiguration();
|
||||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||||
}
|
}
|
||||||
[HttpPost("configs")]
|
[HttpPost("configs")]
|
||||||
@@ -53,6 +58,8 @@ public class AdminController(AppDbContext db) : ControllerBase
|
|||||||
var cfg = new AppConfig { Key = req.Key.Trim(), Value = req.Value ?? "", Version = 1, UpdatedAt = DateTime.UtcNow };
|
var cfg = new AppConfig { Key = req.Key.Trim(), Value = req.Value ?? "", Version = 1, UpdatedAt = DateTime.UtcNow };
|
||||||
db.AppConfigs.Add(cfg);
|
db.AppConfigs.Add(cfg);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
|
||||||
|
llmClient.InvalidateConfiguration();
|
||||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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); }
|
[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(); }
|
[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]);
|
||||||
|
|
||||||
[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 }); }
|
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 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());
|
[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")]
|
[HttpPost("categories")]
|
||||||
@@ -265,10 +413,39 @@ 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) }); }
|
[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) =>
|
private static bool IsSecret(string key) =>
|
||||||
key.Equals("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
|
key.StartsWith("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
||||||
key.Contains("password", 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 UpdateConfigRequest(string Value);
|
||||||
@@ -277,4 +454,11 @@ public record UpsertPersonaRequest(string Key, string Name, string Description,
|
|||||||
public record UpsertAvatarRequest(string Key, string Name, string SpeechTic, string? ImageUrl, 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 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 candidateIds = manifest.Candidates.Select(item => item.CandidateId).ToHashSet();
|
||||||
var evidenceById = manifest.Evidence.ToDictionary(item => item.EvidenceId);
|
var evidenceById = manifest.Evidence.ToDictionary(item => item.EvidenceId);
|
||||||
|
var evidenceOwners = evidenceById.ToDictionary(
|
||||||
|
entry => entry.Key,
|
||||||
|
entry => entry.Value.CandidateId);
|
||||||
var selected = modelActions
|
var selected = modelActions
|
||||||
.Where(action => action.CandidateId != null && candidateIds.Contains(action.CandidateId))
|
.Where(action => action.CandidateId != null && candidateIds.Contains(action.CandidateId))
|
||||||
.GroupBy(action => action.CandidateId!)
|
.GroupBy(action => action.CandidateId!)
|
||||||
@@ -401,7 +404,19 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
|||||||
transferDirection = candidate.TransferDirection;
|
transferDirection = candidate.TransferDirection;
|
||||||
reason = "转账方向不明确,已保留本地结果";
|
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(
|
var category = FindCategory(
|
||||||
categories,
|
categories,
|
||||||
type == "income" || transferDirection == "in"
|
type == "income" || transferDirection == "in"
|
||||||
@@ -465,6 +480,18 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
|||||||
return result.Take(20).ToList();
|
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)
|
private async Task<bool> FeatureEnabled(string key)
|
||||||
{
|
{
|
||||||
var value = await db.AppConfigs
|
var value = await db.AppConfigs
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ builder.Services.AddScoped<AiChatQuotaService>();
|
|||||||
builder.Services.AddScoped<BudgetPushService>();
|
builder.Services.AddScoped<BudgetPushService>();
|
||||||
builder.Services.AddScoped<AdminSessionService>();
|
builder.Services.AddScoped<AdminSessionService>();
|
||||||
builder.Services.AddScoped<AdminBootstrapService>();
|
builder.Services.AddScoped<AdminBootstrapService>();
|
||||||
|
builder.Services.AddSingleton<LlmSecretProtector>();
|
||||||
builder.Services.AddSingleton<PushTokenProtector>();
|
builder.Services.AddSingleton<PushTokenProtector>();
|
||||||
builder.Services.AddScoped<AiPermissionFilter>();
|
builder.Services.AddScoped<AiPermissionFilter>();
|
||||||
builder.Services.AddHttpClient("LlmClient");
|
builder.Services.AddHttpClient("LlmClient");
|
||||||
@@ -165,9 +166,13 @@ using (var scope = app.Services.CreateScope())
|
|||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
|
await AppConfigDefaults.EnsureAsync(db);
|
||||||
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
|
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
|
||||||
if (app.Environment.IsDevelopment())
|
// These records are runtime defaults, not development fixtures. Production
|
||||||
await DbSeeder.SeedAsync(db);
|
// 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())
|
if (app.Environment.IsDevelopment())
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ public sealed class AdminBootstrapService(
|
|||||||
var username = configuration["Admin:BootstrapUsername"]?.Trim();
|
var username = configuration["Admin:BootstrapUsername"]?.Trim();
|
||||||
var password = configuration["Admin:BootstrapPassword"];
|
var password = configuration["Admin:BootstrapPassword"];
|
||||||
if (string.IsNullOrWhiteSpace(username) || username.Length is < 3 or > 64 ||
|
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(
|
throw new InvalidOperationException(
|
||||||
"首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 12 位");
|
"首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 6 位");
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,19 +15,23 @@ public partial class OpenAiVisionClient : ILlmClient
|
|||||||
private readonly IServiceScopeFactory _sf;
|
private readonly IServiceScopeFactory _sf;
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<OpenAiVisionClient> _logger;
|
private readonly ILogger<OpenAiVisionClient> _logger;
|
||||||
|
private readonly LlmSecretProtector _secretProtector;
|
||||||
private string? _baseUrl, _apiKey, _model, _protocol;
|
private string? _baseUrl, _apiKey, _model, _protocol;
|
||||||
private int _maxTokens = 1024;
|
private int _maxTokens = 1024;
|
||||||
|
private double _temperature = 0.7;
|
||||||
private DateTime _last = DateTime.MinValue;
|
private DateTime _last = DateTime.MinValue;
|
||||||
private static readonly object _lk = new();
|
private static readonly object _lk = new();
|
||||||
|
|
||||||
public OpenAiVisionClient(
|
public OpenAiVisionClient(
|
||||||
IServiceScopeFactory sf,
|
IServiceScopeFactory sf,
|
||||||
IHttpClientFactory hf,
|
IHttpClientFactory hf,
|
||||||
|
LlmSecretProtector secretProtector,
|
||||||
ILogger<OpenAiVisionClient> logger)
|
ILogger<OpenAiVisionClient> logger)
|
||||||
{
|
{
|
||||||
_sf = sf;
|
_sf = sf;
|
||||||
_http = hf.CreateClient("LlmClient");
|
_http = hf.CreateClient("LlmClient");
|
||||||
_http.Timeout = TimeSpan.FromSeconds(120);
|
_http.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
_secretProtector = secretProtector;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
|
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
|
||||||
@@ -200,6 +204,7 @@ public partial class OpenAiVisionClient : ILlmClient
|
|||||||
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。
|
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。
|
||||||
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。
|
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。
|
||||||
update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 transferDirection=in|out。
|
update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 transferDirection=in|out。
|
||||||
|
update 修改 amount 时必须引用属于该 candidateId 的 evidenceId,且 confidence 不低于 0.9;证据不足时保持候选金额。
|
||||||
截图未明确显示时间时沿用候选时间,禁止猜测时间。reason 不超过 40 个汉字。
|
截图未明确显示时间时沿用候选时间,禁止猜测时间。reason 不超过 40 个汉字。
|
||||||
""";
|
""";
|
||||||
var messages = new List<object>();
|
var messages = new List<object>();
|
||||||
@@ -680,11 +685,19 @@ public partial class OpenAiVisionClient : ILlmClient
|
|||||||
{
|
{
|
||||||
Load();
|
Load();
|
||||||
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
||||||
if (_protocol != "responses")
|
|
||||||
return (false, "AI Agent 记账要求使用 Responses 协议");
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (_protocol != "responses")
|
||||||
|
{
|
||||||
|
var reply = await L(
|
||||||
|
"你正在执行连接测试,只回复 OK。",
|
||||||
|
"测试连接",
|
||||||
|
ct);
|
||||||
|
return string.IsNullOrWhiteSpace(reply)
|
||||||
|
? (false, "模型没有返回内容")
|
||||||
|
: (true, null);
|
||||||
|
}
|
||||||
var tool = new AgentToolDefinition(
|
var tool = new AgentToolDefinition(
|
||||||
"diagnostic_echo",
|
"diagnostic_echo",
|
||||||
"连接测试时必须调用的无副作用工具",
|
"连接测试时必须调用的无副作用工具",
|
||||||
@@ -715,7 +728,12 @@ public partial class OpenAiVisionClient : ILlmClient
|
|||||||
var msgs = new List<object>();
|
var msgs = new List<object>();
|
||||||
if (_protocol == "messages") msgs.Add(new { role = "user", content = s + "\n\n" + u });
|
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 }); }
|
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()
|
void Load()
|
||||||
@@ -729,20 +747,33 @@ public partial class OpenAiVisionClient : ILlmClient
|
|||||||
using var scope = _sf.CreateScope();
|
using var scope = _sf.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
||||||
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
_apiKey = _secretProtector.TryUnprotect(
|
||||||
|
config.GetValueOrDefault(LlmSecretProtector.ConfigKey),
|
||||||
|
out var protectedApiKey)
|
||||||
|
? protectedApiKey
|
||||||
|
: Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||||
_baseUrl = (
|
_baseUrl = (
|
||||||
|
config.GetValueOrDefault("llm.base_url") ??
|
||||||
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
||||||
config.GetValueOrDefault("llm.base_url", "https://api.openai.com/v1") ??
|
"https://api.openai.com/v1").TrimEnd('/');
|
||||||
"").TrimEnd('/');
|
_model = config.GetValueOrDefault("llm.model") ??
|
||||||
_model = Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
||||||
config.GetValueOrDefault("llm.model", "gpt-4o-mini");
|
"gpt-4o-mini";
|
||||||
_protocol = Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
_protocol = config.GetValueOrDefault("llm.protocol") ??
|
||||||
config.GetValueOrDefault("llm.protocol", "chat_completions");
|
Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
||||||
|
"responses";
|
||||||
_maxTokens = int.TryParse(
|
_maxTokens = int.TryParse(
|
||||||
config.GetValueOrDefault("llm.max_tokens"),
|
config.GetValueOrDefault("llm.max_tokens"),
|
||||||
out var maxTokens)
|
out var maxTokens)
|
||||||
? Math.Clamp(maxTokens, 64, 4096)
|
? Math.Clamp(maxTokens, 64, 4096)
|
||||||
: 1024;
|
: 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;
|
_last = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -753,7 +784,7 @@ public partial class OpenAiVisionClient : ILlmClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
async Task<string?> L(string sys, string user, CancellationToken ct)
|
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
|
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 } };
|
{ "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.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.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", 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.daily_ai_messages_per_user", Value = "50", Version = 1, UpdatedAt = now },
|
||||||
new AppConfig { Key = "limit.max_monthly_budget", Value = "99999999", Version = 1, UpdatedAt = now },
|
new AppConfig { Key = "limit.max_monthly_budget", Value = "99999999", Version = 1, UpdatedAt = now },
|
||||||
|
|||||||
+5
-1
@@ -44,7 +44,7 @@
|
|||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
export Admin__BootstrapUsername='admin'
|
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
|
dotnet build
|
||||||
# 重启
|
# 重启
|
||||||
powershell -Command "Get-Process dotnet | Stop-Process -Force"
|
powershell -Command "Get-Process dotnet | Stop-Process -Force"
|
||||||
@@ -56,6 +56,10 @@ dotnet run --project MiaoJiZhang.Api
|
|||||||
引导变量。正式环境必须使用 HTTPS 并保持 `Admin__CookieSecure=true`。本地纯 HTTP 调试时才可
|
引导变量。正式环境必须使用 HTTPS 并保持 `Admin__CookieSecure=true`。本地纯 HTTP 调试时才可
|
||||||
临时设置 `Admin__CookieSecure=false`。
|
临时设置 `Admin__CookieSecure=false`。
|
||||||
|
|
||||||
|
后台“AI 配置 → 模型服务”可以直接保存和替换 LLM API Key。实际 API Key 使用 AES-GCM
|
||||||
|
加密后写入配置表,加密密钥由服务端从必填的 `Jwt__Secret` 自动派生,无需增加部署变量;
|
||||||
|
页面和接口只显示 API Key 尾号。旧的 `LLM_API_KEY` 仍作为回退配置,后台保存的密钥优先。
|
||||||
|
|
||||||
### 2. Admin Web
|
### 2. Admin Web
|
||||||
```powershell
|
```powershell
|
||||||
cd admin-web
|
cd admin-web
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<uses-permission android:name="android.permission.POST_PROMOTED_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"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
|
<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
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -54,7 +56,7 @@
|
|||||||
android:name=".OneShotProjectionService"
|
android:name=".OneShotProjectionService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaProjection"
|
android:foregroundServiceType="mediaProjection"
|
||||||
android:process=":recognition"
|
android:process=":projection"
|
||||||
android:stopWithTask="false"/>
|
android:stopWithTask="false"/>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
@@ -63,6 +65,7 @@
|
|||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/screenshot_tile_label"
|
android:label="@string/screenshot_tile_label"
|
||||||
android:process=":recognition"
|
android:process=":recognition"
|
||||||
|
android:stopWithTask="false"
|
||||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE"/>
|
<action android:name="android.service.quicksettings.action.QS_TILE"/>
|
||||||
@@ -74,6 +77,7 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/notification_listener_label"
|
android:label="@string/notification_listener_label"
|
||||||
android:process=":recognition"
|
android:process=":recognition"
|
||||||
|
android:stopWithTask="false"
|
||||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.service.notification.NotificationListenerService"/>
|
<action android:name="android.service.notification.NotificationListenerService"/>
|
||||||
@@ -84,6 +88,7 @@
|
|||||||
android:name=".ScreenshotAccessibilityService"
|
android:name=".ScreenshotAccessibilityService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:process=":recognition"
|
android:process=":recognition"
|
||||||
|
android:stopWithTask="false"
|
||||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.accessibilityservice.AccessibilityService"/>
|
<action android:name="android.accessibilityservice.AccessibilityService"/>
|
||||||
@@ -93,6 +98,28 @@
|
|||||||
android:resource="@xml/accessibility_service_config"/>
|
android:resource="@xml/accessibility_service_config"/>
|
||||||
</service>
|
</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
|
<provider
|
||||||
android:name=".RecognitionBridgeProvider"
|
android:name=".RecognitionBridgeProvider"
|
||||||
android:authorities="${applicationId}.recognition.bridge"
|
android:authorities="${applicationId}.recognition.bridge"
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ object LocalPaymentOcr {
|
|||||||
flowSessionId: String,
|
flowSessionId: String,
|
||||||
trustedFlow: Boolean,
|
trustedFlow: Boolean,
|
||||||
capturedAt: Long,
|
capturedAt: Long,
|
||||||
expectedAmountCents: Long?,
|
expectedAmountEvidence: ExpectedAmountEvidence?,
|
||||||
expectedType: String?,
|
expectedType: String?,
|
||||||
resultTransitionObserved: Boolean,
|
resultTransitionObserved: Boolean,
|
||||||
submittedFlow: Boolean,
|
submittedFlow: Boolean,
|
||||||
@@ -120,7 +120,7 @@ object LocalPaymentOcr {
|
|||||||
flowSessionId = flowSessionId,
|
flowSessionId = flowSessionId,
|
||||||
trustedFlow = trustedFlow,
|
trustedFlow = trustedFlow,
|
||||||
capturedAt = capturedAt,
|
capturedAt = capturedAt,
|
||||||
expectedAmountCents = expectedAmountCents,
|
expectedAmountEvidence = expectedAmountEvidence,
|
||||||
expectedType = expectedType,
|
expectedType = expectedType,
|
||||||
resultTransitionObserved = resultTransitionObserved,
|
resultTransitionObserved = resultTransitionObserved,
|
||||||
submittedFlow = submittedFlow,
|
submittedFlow = submittedFlow,
|
||||||
@@ -199,7 +199,7 @@ object LocalPaymentOcr {
|
|||||||
flowSessionId: String,
|
flowSessionId: String,
|
||||||
trustedFlow: Boolean,
|
trustedFlow: Boolean,
|
||||||
capturedAt: Long,
|
capturedAt: Long,
|
||||||
expectedAmountCents: Long?,
|
expectedAmountEvidence: ExpectedAmountEvidence?,
|
||||||
expectedType: String?,
|
expectedType: String?,
|
||||||
resultTransitionObserved: Boolean,
|
resultTransitionObserved: Boolean,
|
||||||
submittedFlow: Boolean,
|
submittedFlow: Boolean,
|
||||||
@@ -322,6 +322,7 @@ object LocalPaymentOcr {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
val distinctCents = ranked.map { (it.amount * 100).roundToLong() }.distinct()
|
val distinctCents = ranked.map { (it.amount * 100).roundToLong() }.distinct()
|
||||||
|
val expectedAmountCents = expectedAmountEvidence?.amountCents
|
||||||
val expectedCandidate = expectedAmountCents?.let { expected ->
|
val expectedCandidate = expectedAmountCents?.let { expected ->
|
||||||
ranked.firstOrNull { (it.amount * 100).roundToLong() == expected }
|
ranked.firstOrNull { (it.amount * 100).roundToLong() == expected }
|
||||||
}
|
}
|
||||||
@@ -348,15 +349,21 @@ object LocalPaymentOcr {
|
|||||||
|
|
||||||
val selectedCents = resultSelectedCents ?: expectedAmountCents!!
|
val selectedCents = resultSelectedCents ?: expectedAmountCents!!
|
||||||
val amountSource = if (resultSelectedCents == null) "expected" else "result"
|
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 fresh = System.currentTimeMillis() - capturedAt <= MAX_SCREENSHOT_AGE_MS
|
||||||
val resultAmountSafe = distinctCents.size == 1 &&
|
val resultAmountSafe = distinctCents.size == 1 &&
|
||||||
(expectedAmountCents == null || expectedMatched == true)
|
(expectedAmountCents == null || expectedMatched == true)
|
||||||
val highConfidence = when {
|
val highConfidence = when {
|
||||||
amountSource == "expected" -> trustedFlow && fresh && submittedFlow &&
|
amountSource == "expected" -> expectedAmountEvidence?.isStrong == true &&
|
||||||
resultTransitionObserved && expectedType == direction
|
trustedFlow && fresh && submittedFlow && resultTransitionObserved &&
|
||||||
|
expectedType == direction
|
||||||
status.strength == PaymentStatusStrength.STRONG -> trustedFlow && fresh &&
|
status.strength == PaymentStatusStrength.STRONG -> trustedFlow && fresh &&
|
||||||
submittedFlow && resultTransitionObserved && resultAmountSafe
|
submittedFlow && resultTransitionObserved && resultAmountSafe && !amountConflict
|
||||||
status.strength == PaymentStatusStrength.WEAK -> OcrEvidenceEvaluator.qualifiesWeakAuto(
|
status.strength == PaymentStatusStrength.WEAK -> OcrEvidenceEvaluator.qualifiesWeakAuto(
|
||||||
trustedFlow = trustedFlow && submittedFlow,
|
trustedFlow = trustedFlow && submittedFlow,
|
||||||
freshScreenshot = fresh,
|
freshScreenshot = fresh,
|
||||||
@@ -399,8 +406,27 @@ object LocalPaymentOcr {
|
|||||||
if (it == "income") "in" else "out"
|
if (it == "income") "in" else "out"
|
||||||
},
|
},
|
||||||
counterparty = merchant.takeIf { kind == "transfer" },
|
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 {
|
val reason = when {
|
||||||
|
amountConflict -> "result_amount_conflict"
|
||||||
|
amountSource == "expected" && expectedAmountEvidence?.isStrong != true ->
|
||||||
|
"weak_expected_fallback"
|
||||||
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
|
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
|
||||||
highConfidence && status.strength == PaymentStatusStrength.WEAK ->
|
highConfidence && status.strength == PaymentStatusStrength.WEAK ->
|
||||||
"combined_high_confidence"
|
"combined_high_confidence"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.nx.miaoji
|
package com.nx.miaoji
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.app.ApplicationExitInfo
|
||||||
import android.app.StatusBarManager
|
import android.app.StatusBarManager
|
||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
@@ -44,10 +46,12 @@ class MainActivity : FlutterActivity() {
|
|||||||
const val ACTION_RECOGNITION_UNDO = "recognition_undo"
|
const val ACTION_RECOGNITION_UNDO = "recognition_undo"
|
||||||
const val ACTION_RECOGNITION_EDIT = "recognition_edit"
|
const val ACTION_RECOGNITION_EDIT = "recognition_edit"
|
||||||
const val ACTION_RECOGNITION_BATCH_REVIEW = "recognition_batch_review"
|
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_PATH = "screenshotPath"
|
||||||
const val EXTRA_SCREENSHOT_ERROR = "screenshotError"
|
const val EXTRA_SCREENSHOT_ERROR = "screenshotError"
|
||||||
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
|
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
|
||||||
const val EXTRA_TRANSACTION_ID = "transactionId"
|
const val EXTRA_TRANSACTION_ID = "transactionId"
|
||||||
|
private const val TASK_CLEANER_WINDOW_MS = 15_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
private val handler by lazy { Handler(mainLooper) }
|
private val handler by lazy { Handler(mainLooper) }
|
||||||
@@ -70,6 +74,11 @@ class MainActivity : FlutterActivity() {
|
|||||||
private var speechRecognizer: SpeechRecognizer? = 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) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
updateInstallBridge = UpdateInstallBridge(this).also { it.register(flutterEngine) }
|
updateInstallBridge = UpdateInstallBridge(this).also { it.register(flutterEngine) }
|
||||||
@@ -181,7 +190,16 @@ class MainActivity : FlutterActivity() {
|
|||||||
putString("candidateId", call.argument<String>("candidateId"))
|
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" -> {
|
"openAccessibilitySettings" -> {
|
||||||
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||||
@@ -226,6 +244,8 @@ class MainActivity : FlutterActivity() {
|
|||||||
scheduleShortcutIfNeeded()
|
scheduleShortcutIfNeeded()
|
||||||
dispatchPendingScreenshot()
|
dispatchPendingScreenshot()
|
||||||
dispatchPendingRecognitionAction()
|
dispatchPendingRecognitionAction()
|
||||||
|
ensureRecognitionKeepAlive()
|
||||||
|
updateRecentsProtection()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIncomingIntent(incoming: Intent?) {
|
private fun handleIncomingIntent(incoming: Intent?) {
|
||||||
@@ -234,6 +254,13 @@ class MainActivity : FlutterActivity() {
|
|||||||
shortcutQueued = true
|
shortcutQueued = true
|
||||||
scheduleShortcutIfNeeded()
|
scheduleShortcutIfNeeded()
|
||||||
}
|
}
|
||||||
|
ACTION_OPEN_RECOGNITION_SETTINGS -> {
|
||||||
|
pendingRecognitionAction = mapOf(
|
||||||
|
"action" to ACTION_OPEN_RECOGNITION_SETTINGS,
|
||||||
|
)
|
||||||
|
dispatchPendingRecognitionAction()
|
||||||
|
incoming.removeExtra(EXTRA_ACTION)
|
||||||
|
}
|
||||||
ACTION_SCREENSHOT_RESULT -> {
|
ACTION_SCREENSHOT_RESULT -> {
|
||||||
val path = incoming.getStringExtra(EXTRA_SCREENSHOT_PATH)
|
val path = incoming.getStringExtra(EXTRA_SCREENSHOT_PATH)
|
||||||
val sessionId =
|
val sessionId =
|
||||||
@@ -791,9 +818,54 @@ class MainActivity : FlutterActivity() {
|
|||||||
.split(':')
|
.split(':')
|
||||||
.mapNotNull(ComponentName::unflattenFromString)
|
.mapNotNull(ComponentName::unflattenFromString)
|
||||||
.any { it == ComponentName(this, PaymentNotificationListenerService::class.java) }
|
.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(
|
return mapOf(
|
||||||
"accessibilityAuthorized" to isAccessibilityEnabledInSystem(),
|
"accessibilityAuthorized" to accessibilityAuthorized,
|
||||||
"accessibilityConnected" to (response?.getBoolean("accessibilityConnected") == true),
|
"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,
|
"notificationAuthorized" to notificationAuthorized,
|
||||||
"notificationConnected" to (response?.getBoolean("notificationConnected") == true),
|
"notificationConnected" to (response?.getBoolean("notificationConnected") == true),
|
||||||
"postNotificationsGranted" to (
|
"postNotificationsGranted" to (
|
||||||
@@ -815,6 +887,76 @@ class MainActivity : FlutterActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private fun acknowledgeRecognition(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val response = RecognitionBridge.call(
|
val response = RecognitionBridge.call(
|
||||||
this,
|
this,
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ class PaymentNotificationListenerService : NotificationListenerService() {
|
|||||||
override fun onListenerConnected() {
|
override fun onListenerConnected() {
|
||||||
super.onListenerConnected()
|
super.onListenerConnected()
|
||||||
isConnected = true
|
isConnected = true
|
||||||
|
RecognitionKeepAliveService.ensureRunning(this)
|
||||||
Log.i(TAG, "Notification recognition listener connected")
|
Log.i(TAG, "Notification recognition listener connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,36 @@ data class PaymentSignal(
|
|||||||
val identityConfidence: String = "strong",
|
val identityConfidence: String = "strong",
|
||||||
val transferDirection: String? = null,
|
val transferDirection: String? = null,
|
||||||
val counterparty: 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) {
|
enum class PaymentStatusStrength(val wireValue: String) {
|
||||||
@@ -44,7 +74,7 @@ object PaymentParser {
|
|||||||
val supportedPackages = setOf(WECHAT, ALIPAY)
|
val supportedPackages = setOf(WECHAT, ALIPAY)
|
||||||
|
|
||||||
private val amountPatterns = listOf(
|
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("""[¥¥]\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
|
||||||
Regex("""([0-9]+(?:\.[0-9]{1,2})?)\s*元"""),
|
Regex("""([0-9]+(?:\.[0-9]{1,2})?)\s*元"""),
|
||||||
)
|
)
|
||||||
@@ -95,7 +125,8 @@ object PaymentParser {
|
|||||||
)
|
)
|
||||||
private val paymentInputWords = listOf(
|
private val paymentInputWords = listOf(
|
||||||
"输入支付密码", "请输入支付密码", "确认转账", "确认支付", "确认付款",
|
"输入支付密码", "请输入支付密码", "确认转账", "确认支付", "确认付款",
|
||||||
"立即支付", "立即付款", "继续付款",
|
"立即支付", "立即付款", "继续付款", "转账全额", "添加转账说明",
|
||||||
|
"请输入转账金额", "输入转账金额",
|
||||||
)
|
)
|
||||||
private val redPacketSendContextWords = listOf(
|
private val redPacketSendContextWords = listOf(
|
||||||
"发红包", "塞钱进红包", "红包金额", "发送红包", "普通红包", "拼手气红包",
|
"发红包", "塞钱进红包", "红包金额", "发送红包", "普通红包", "拼手气红包",
|
||||||
@@ -128,17 +159,47 @@ object PaymentParser {
|
|||||||
windowId: Int,
|
windowId: Int,
|
||||||
flowSessionId: String? = null,
|
flowSessionId: String? = null,
|
||||||
trustedFlow: Boolean = false,
|
trustedFlow: Boolean = false,
|
||||||
expectedAmountCents: Long? = null,
|
expectedAmountEvidence: ExpectedAmountEvidence? = null,
|
||||||
expectedType: String? = null,
|
expectedType: String? = null,
|
||||||
resultTransitionObserved: Boolean = false,
|
resultTransitionObserved: Boolean = false,
|
||||||
submittedFlow: Boolean = false,
|
submittedFlow: Boolean = false,
|
||||||
flowKind: String? = null,
|
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 ||
|
if (packageName !in supportedPackages ||
|
||||||
containsBlockedStatus(text) ||
|
containsBlockedStatus(text) ||
|
||||||
isHistoryPageText(text) ||
|
isHistoryPageText(text) ||
|
||||||
isPaymentInputPage(text)
|
isPaymentInputPage(text)
|
||||||
) return null
|
) return PaymentParseOutcome(null, resultCandidates.size, null, null, null)
|
||||||
val eventId = flowSessionId?.let { "a:" + packageName + ":" + it }
|
val eventId = flowSessionId?.let { "a:" + packageName + ":" + it }
|
||||||
?: "a:" + windowId + ":" + sha256(normalize(text)) + ":" + (eventTime / 10_000L)
|
?: "a:" + windowId + ":" + sha256(normalize(text)) + ":" + (eventTime / 10_000L)
|
||||||
val status = detectStatus(text, expectedType)
|
val status = detectStatus(text, expectedType)
|
||||||
@@ -156,46 +217,85 @@ object PaymentParser {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
val uniqueResultAmount = uniqueAmountCents(text)
|
|
||||||
val standaloneRedPacketIncome = parsed.recognitionKind in setOf(
|
val standaloneRedPacketIncome = parsed.recognitionKind in setOf(
|
||||||
"red_packet_receive",
|
"red_packet_receive",
|
||||||
"red_packet_refund",
|
"red_packet_refund",
|
||||||
)
|
)
|
||||||
val expectedMatches = expectedAmountCents == null ||
|
val expectedMatches = expectedCents == null ||
|
||||||
uniqueResultAmount == expectedAmountCents
|
uniqueResultAmount == expectedCents
|
||||||
|
val conflict = expectedCents != null && uniqueResultAmount != null &&
|
||||||
|
uniqueResultAmount != expectedCents
|
||||||
val evidenceHigh = when {
|
val evidenceHigh = when {
|
||||||
standaloneRedPacketIncome -> uniqueResultAmount == parsed.amountCents
|
standaloneRedPacketIncome -> uniqueResultAmount == parsed.amountCents
|
||||||
submittedFlow -> trustedFlow && resultTransitionObserved &&
|
submittedFlow -> trustedFlow && resultTransitionObserved &&
|
||||||
uniqueResultAmount == parsed.amountCents && expectedMatches
|
uniqueResultAmount == parsed.amountCents && expectedMatches && !conflict
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
return parsed.copy(
|
val signal = parsed.copy(
|
||||||
evidenceConfidence = if (evidenceHigh) "high" else "confirm",
|
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" &&
|
val redPacketSentSurface = flowKind == "red_packet_send" &&
|
||||||
hasRedPacketSentSurface(text)
|
hasRedPacketSentSurface(text)
|
||||||
if (status.strength == PaymentStatusStrength.NONE && !redPacketSentSurface) return null
|
if (status.strength == PaymentStatusStrength.NONE && !redPacketSentSurface) {
|
||||||
val direction = status.direction ?: if (redPacketSentSurface) "expense" else return null
|
return PaymentParseOutcome(null, resultCandidates.size, null, null, null)
|
||||||
val resultAmount = uniqueAmountCents(text)
|
}
|
||||||
|
val direction = status.direction ?: if (redPacketSentSurface) "expense" else {
|
||||||
|
return PaymentParseOutcome(null, resultCandidates.size, null, null, "direction_unknown")
|
||||||
|
}
|
||||||
|
val resultAmount = uniqueResultAmount
|
||||||
val canUseExpectedAmount = resultAmount == null &&
|
val canUseExpectedAmount = resultAmount == null &&
|
||||||
expectedAmountCents != null &&
|
expectedCents != null &&
|
||||||
submittedFlow &&
|
submittedFlow &&
|
||||||
resultTransitionObserved &&
|
resultTransitionObserved &&
|
||||||
expectedType == direction
|
expectedType == direction
|
||||||
val amountCents = resultAmount ?: expectedAmountCents?.takeIf { canUseExpectedAmount }
|
val amountCents = resultAmount ?: expectedCents?.takeIf { canUseExpectedAmount }
|
||||||
?: return null
|
?: return PaymentParseOutcome(
|
||||||
val expectedMatched = expectedAmountCents != null && amountCents == expectedAmountCents
|
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 &&
|
val evidenceHigh = trustedFlow && submittedFlow && resultTransitionObserved &&
|
||||||
expectedMatched && expectedType == direction
|
expectedType == direction && !conflict &&
|
||||||
|
(resultAmount != null || expectedStrong)
|
||||||
val kind = flowKind ?: recognitionKind(text, direction)
|
val kind = flowKind ?: recognitionKind(text, direction)
|
||||||
val merchant = extractMerchant(text)
|
val merchant = extractMerchant(text)
|
||||||
val orderId = extractOrderId(text)
|
val orderId = extractOrderId(text)
|
||||||
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
||||||
if (it == "income") "in" else "out"
|
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,
|
packageName = packageName,
|
||||||
channel = "accessibility",
|
channel = "accessibility",
|
||||||
amountCents = amountCents,
|
amountCents = amountCents,
|
||||||
@@ -222,6 +322,17 @@ object PaymentParser {
|
|||||||
),
|
),
|
||||||
transferDirection = transferDirection,
|
transferDirection = transferDirection,
|
||||||
counterparty = merchant.takeIf { kind == "transfer" },
|
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? {
|
fun fromNotification(notification: StatusBarNotification): PaymentSignal? {
|
||||||
@@ -313,8 +424,12 @@ object PaymentParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun uniqueAmountCents(value: String): Long? {
|
fun uniqueAmountCents(value: String): Long? {
|
||||||
|
return amountCandidateCents(value).singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun amountCandidateCents(value: String): List<Long> {
|
||||||
val normalized = normalize(value)
|
val normalized = normalize(value)
|
||||||
val cents = buildList {
|
return buildList {
|
||||||
amountPatterns.forEach { pattern ->
|
amountPatterns.forEach { pattern ->
|
||||||
pattern.findAll(normalized).forEach { match ->
|
pattern.findAll(normalized).forEach { match ->
|
||||||
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let { amount ->
|
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let { amount ->
|
||||||
@@ -332,13 +447,63 @@ object PaymentParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.distinct()
|
}.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 {
|
fun isPaymentInputPage(value: String): Boolean {
|
||||||
val normalized = normalize(value)
|
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) ||
|
return paymentInputWords.any(normalized::contains) ||
|
||||||
redPacketSendActionWords.any(normalized::contains)
|
redPacketSendActionWords.any(normalized::contains) ||
|
||||||
|
transferInputComposite
|
||||||
}
|
}
|
||||||
|
|
||||||
fun detectFlowKind(value: String): String? {
|
fun detectFlowKind(value: String): String? {
|
||||||
@@ -408,12 +573,17 @@ object PaymentParser {
|
|||||||
val normalized = normalize(value)
|
val normalized = normalize(value)
|
||||||
amountPatterns.firstNotNullOfOrNull { pattern ->
|
amountPatterns.firstNotNullOfOrNull { pattern ->
|
||||||
pattern.find(normalized)?.groupValues?.getOrNull(1)?.toDoubleOrNull()
|
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 match = STANDALONE_AMOUNT.matchEntire(normalized) ?: return null
|
||||||
val number = match.groupValues[2]
|
val number = match.groupValues[2]
|
||||||
val hasCurrencyOrUnit = match.groupValues[1].isNotEmpty() ||
|
val hasCurrencyOrUnit = match.groupValues[1].isNotEmpty() ||
|
||||||
match.groupValues[3].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? =
|
fun extractOrderId(value: String): String? =
|
||||||
@@ -548,6 +718,10 @@ object PaymentParser {
|
|||||||
private val STANDALONE_AMOUNT = Regex(
|
private val STANDALONE_AMOUNT = Regex(
|
||||||
"""^\s*([¥¥]?)\s*([0-9]{1,8}(?:\.[0-9]{1,2})?)\s*(元?)\s*$""",
|
"""^\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_SUCCESS_HEADING_CHARS = 48
|
||||||
private const val MAX_PAYMENT_SCAN_LINES = 48
|
private const val MAX_PAYMENT_SCAN_LINES = 48
|
||||||
private const val MAX_HISTORY_TITLE_LINES = 3
|
private const val MAX_HISTORY_TITLE_LINES = 3
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class RecognitionBridgeProvider : ContentProvider() {
|
|||||||
private val captureResults = ConcurrentHashMap<String, CaptureResult>()
|
private val captureResults = ConcurrentHashMap<String, CaptureResult>()
|
||||||
|
|
||||||
override fun onCreate(): Boolean {
|
override fun onCreate(): Boolean {
|
||||||
|
context?.let {
|
||||||
|
RecognitionConnectionStore.markRecognitionProcessStarted(it, PROCESS_STARTED_AT)
|
||||||
|
}
|
||||||
context?.let(RecognitionCoordinator::get)
|
context?.let(RecognitionCoordinator::get)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -22,20 +25,40 @@ class RecognitionBridgeProvider : ContentProvider() {
|
|||||||
return when (method) {
|
return when (method) {
|
||||||
METHOD_STATUS -> Bundle().apply {
|
METHOD_STATUS -> Bundle().apply {
|
||||||
putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected)
|
putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected)
|
||||||
|
putLong(
|
||||||
|
"accessibilityLastConnectedAt",
|
||||||
|
RecognitionConnectionStore.lastConnectedAt(appContext),
|
||||||
|
)
|
||||||
|
putLong(
|
||||||
|
"accessibilityLastDisconnectedAt",
|
||||||
|
RecognitionConnectionStore.lastDisconnectedAt(appContext),
|
||||||
|
)
|
||||||
|
putLong("recognitionProcessStartedAt", PROCESS_STARTED_AT)
|
||||||
putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected)
|
putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected)
|
||||||
|
putBoolean(
|
||||||
|
"keepAliveExpected",
|
||||||
|
RecognitionKeepAliveService.isExpected(appContext),
|
||||||
|
)
|
||||||
|
putBoolean("keepAliveRunning", RecognitionKeepAliveService.isRunning)
|
||||||
|
putString("keepAliveError", RecognitionKeepAliveService.lastStartError)
|
||||||
putString("settings", RecognitionSettings.statusJson(appContext))
|
putString("settings", RecognitionSettings.statusJson(appContext))
|
||||||
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
|
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
|
||||||
putString("latestDiagnostic", RecognitionDiagnostics.latest(appContext))
|
putString("latestDiagnostic", RecognitionDiagnostics.latest(appContext))
|
||||||
}
|
}
|
||||||
METHOD_SET_TOGGLE -> Bundle().apply {
|
METHOD_SET_TOGGLE -> {
|
||||||
putBoolean(
|
val changed = RecognitionSettings.setToggle(
|
||||||
"success",
|
appContext,
|
||||||
RecognitionSettings.setToggle(
|
extras?.getString("key").orEmpty(),
|
||||||
appContext,
|
extras?.getBoolean("enabled") ?: false,
|
||||||
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 {
|
METHOD_CLEAR_DIAGNOSTIC -> Bundle().apply {
|
||||||
RecognitionDiagnostics.clear(appContext)
|
RecognitionDiagnostics.clear(appContext)
|
||||||
@@ -51,6 +74,9 @@ class RecognitionBridgeProvider : ContentProvider() {
|
|||||||
)
|
)
|
||||||
putBoolean("success", true)
|
putBoolean("success", true)
|
||||||
}
|
}
|
||||||
|
METHOD_ENSURE_KEEP_ALIVE -> Bundle().apply {
|
||||||
|
putBoolean("success", RecognitionKeepAliveService.reconcile(appContext))
|
||||||
|
}
|
||||||
METHOD_REQUEST_SCREENSHOT -> requestScreenshot(extras)
|
METHOD_REQUEST_SCREENSHOT -> requestScreenshot(extras)
|
||||||
METHOD_SCREENSHOT_RESULT -> takeScreenshotResult(arg)
|
METHOD_SCREENSHOT_RESULT -> takeScreenshotResult(arg)
|
||||||
METHOD_DRAIN -> Bundle().apply {
|
METHOD_DRAIN -> Bundle().apply {
|
||||||
@@ -143,9 +169,11 @@ class RecognitionBridgeProvider : ContentProvider() {
|
|||||||
private data class CaptureResult(val path: String?, val error: String?)
|
private data class CaptureResult(val path: String?, val error: String?)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private val PROCESS_STARTED_AT = System.currentTimeMillis()
|
||||||
const val METHOD_STATUS = "status"
|
const val METHOD_STATUS = "status"
|
||||||
const val METHOD_SET_TOGGLE = "setToggle"
|
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_CLEAR_DIAGNOSTIC = "clearDiagnostic"
|
||||||
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
|
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
|
||||||
const val METHOD_SCREENSHOT_RESULT = "screenshotResult"
|
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>,
|
val images: List<PendingBatchImage>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal data class AmountMergeResult(
|
||||||
|
val amountCents: Long,
|
||||||
|
val source: String,
|
||||||
|
val strength: String,
|
||||||
|
val conflict: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
class RecognitionStore(context: Context) :
|
class RecognitionStore(context: Context) :
|
||||||
SQLiteOpenHelper(context, "recognition_queue.db", null, 3) {
|
SQLiteOpenHelper(context, "recognition_queue.db", null, 3) {
|
||||||
override fun onCreate(db: SQLiteDatabase) {
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
@@ -178,20 +185,27 @@ class RecognitionStore(context: Context) :
|
|||||||
id = existing.id
|
id = existing.id
|
||||||
val mergedMask = existing.channelMask or channelBit
|
val mergedMask = existing.channelMask or channelBit
|
||||||
val hasNonAiEvidence = mergedMask and channelBit("recognition_ai").inv() != 0
|
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 ||
|
(signalHigh ||
|
||||||
(mergedMask and ACCESSIBILITY_NOTIFICATION_MASK) ==
|
(mergedMask and ACCESSIBILITY_NOTIFICATION_MASK) ==
|
||||||
ACCESSIBILITY_NOTIFICATION_MASK ||
|
ACCESSIBILITY_NOTIFICATION_MASK ||
|
||||||
existing.highConfidence)
|
existing.highConfidence)
|
||||||
val mergedPayload = mergePayload(existing.payload, signal)
|
|
||||||
writableDatabase.update(
|
writableDatabase.update(
|
||||||
"candidates",
|
"candidates",
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("channel_mask", mergedMask)
|
put("channel_mask", mergedMask)
|
||||||
put("known_template", if (existing.knownTemplate || signal.knownTemplate) 1 else 0)
|
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("high_confidence", if (high) 1 else 0)
|
||||||
put("payload_encrypted", encryptPayload(mergedPayload))
|
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("updated_at", now)
|
||||||
put("available_at", now + MERGE_DELAY_MS)
|
put("available_at", now + MERGE_DELAY_MS)
|
||||||
if (batchId != null) {
|
if (batchId != null) {
|
||||||
@@ -525,9 +539,21 @@ class RecognitionStore(context: Context) :
|
|||||||
val original = JSONObject(row.payload.toString())
|
val original = JSONObject(row.payload.toString())
|
||||||
val kind = action.optString("action")
|
val kind = action.optString("action")
|
||||||
val payload = JSONObject(row.payload.toString())
|
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")
|
payload.put("sourceOverride", "recognition_ai")
|
||||||
val reason = action.optString("reason", "AI 对账")
|
val reason = action.optString("reason", "AI 对账")
|
||||||
|
val amountConflict = payload.optBoolean("amountConflict", false)
|
||||||
writableDatabase.update(
|
writableDatabase.update(
|
||||||
"candidates",
|
"candidates",
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
@@ -540,8 +566,15 @@ class RecognitionStore(context: Context) :
|
|||||||
put("channel_mask", row.channelMask or channelBit("recognition_ai"))
|
put("channel_mask", row.channelMask or channelBit("recognition_ai"))
|
||||||
put("ai_action", kind)
|
put("ai_action", kind)
|
||||||
put("ai_reason", reason.take(80))
|
put("ai_reason", reason.take(80))
|
||||||
put("state", if (kind == "drop") "ai_dropped" else "auto_ready")
|
put(
|
||||||
put("high_confidence", 1)
|
"state",
|
||||||
|
when {
|
||||||
|
kind == "drop" -> "ai_dropped"
|
||||||
|
amountConflict -> "pending_confirm"
|
||||||
|
else -> "auto_ready"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put("high_confidence", if (amountConflict) 0 else 1)
|
||||||
put("updated_at", now)
|
put("updated_at", now)
|
||||||
},
|
},
|
||||||
"id = ? AND batch_id = ?",
|
"id = ? AND batch_id = ?",
|
||||||
@@ -631,14 +664,17 @@ class RecognitionStore(context: Context) :
|
|||||||
// The server guarantees one action per candidate. Preserve anything omitted
|
// The server guarantees one action per candidate. Preserve anything omitted
|
||||||
// by a malformed response instead of silently losing a payment.
|
// by a malformed response instead of silently losing a payment.
|
||||||
writableDatabase.rawQuery(
|
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),
|
arrayOf(batchId),
|
||||||
).use { cursor ->
|
).use { cursor ->
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
|
val amountConflict = decryptPayload(cursor.getString(1))
|
||||||
|
?.optBoolean("amountConflict", false) == true
|
||||||
writableDatabase.update(
|
writableDatabase.update(
|
||||||
"candidates",
|
"candidates",
|
||||||
ContentValues().apply {
|
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_action", "keep")
|
||||||
put("ai_reason", "AI 未返回该候选,已保留本地结果")
|
put("ai_reason", "AI 未返回该候选,已保留本地结果")
|
||||||
put("updated_at", now)
|
put("updated_at", now)
|
||||||
@@ -1011,6 +1047,10 @@ class RecognitionStore(context: Context) :
|
|||||||
.put("recognitionKind", signal.recognitionKind)
|
.put("recognitionKind", signal.recognitionKind)
|
||||||
.put("categoryHint", signal.categoryHint)
|
.put("categoryHint", signal.categoryHint)
|
||||||
.put("amountSource", signal.amountSource)
|
.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("resultFingerprint", signal.resultFingerprint)
|
||||||
.put("identityConfidence", signal.identityConfidence)
|
.put("identityConfidence", signal.identityConfidence)
|
||||||
.put("transferDirection", signal.transferDirection)
|
.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.isNull("sourceText") && signal.sourceText != null) existing.put("sourceText", signal.sourceText)
|
||||||
if (existing.optString("recognitionKind").isBlank()) existing.put("recognitionKind", signal.recognitionKind)
|
if (existing.optString("recognitionKind").isBlank()) existing.put("recognitionKind", signal.recognitionKind)
|
||||||
if (existing.isNull("categoryHint") && signal.categoryHint != null) existing.put("categoryHint", signal.categoryHint)
|
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) {
|
if (existing.isNull("resultFingerprint") && signal.resultFingerprint != null) {
|
||||||
existing.put("resultFingerprint", signal.resultFingerprint)
|
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_ITEMS = 10
|
||||||
private const val MAX_BATCH_IMAGE_BYTES = 1024 * 1024
|
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 {
|
internal fun clientRequestIdFor(signal: PaymentSignal): String {
|
||||||
val basis = when {
|
val basis = when {
|
||||||
!signal.orderId.isNullOrBlank() ->
|
!signal.orderId.isNullOrBlank() ->
|
||||||
|
|||||||
+210
-47
@@ -30,6 +30,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
private var lastVisualCaptureAt = 0L
|
private var lastVisualCaptureAt = 0L
|
||||||
private var paymentFlow: PaymentFlow? = null
|
private var paymentFlow: PaymentFlow? = null
|
||||||
private var pendingVisualCapture: Runnable? = null
|
private var pendingVisualCapture: Runnable? = null
|
||||||
|
private var pendingVisualCaptureAt = 0L
|
||||||
private var ocrInProgress = false
|
private var ocrInProgress = false
|
||||||
private var visualOperationId: String? = null
|
private var visualOperationId: String? = null
|
||||||
private var visualTimeout: Runnable? = null
|
private var visualTimeout: Runnable? = null
|
||||||
@@ -47,7 +48,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
var kind: String,
|
var kind: String,
|
||||||
var originWindowId: Int = windowId,
|
var originWindowId: Int = windowId,
|
||||||
var originPageHash: String? = null,
|
var originPageHash: String? = null,
|
||||||
var expectedAmountCents: Long? = null,
|
var expectedAmountEvidence: ExpectedAmountEvidence? = null,
|
||||||
var expectedType: String? = null,
|
var expectedType: String? = null,
|
||||||
var committedAt: Long? = null,
|
var committedAt: Long? = null,
|
||||||
var completedAt: Long? = null,
|
var completedAt: Long? = null,
|
||||||
@@ -58,6 +59,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
var resultSurfaceExited: Boolean = false,
|
var resultSurfaceExited: Boolean = false,
|
||||||
var retryCount: Int = 0,
|
var retryCount: Int = 0,
|
||||||
var probeCount: Int = 0,
|
var probeCount: Int = 0,
|
||||||
|
var trackingTimedOut: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class CaptureRequest(
|
private data class CaptureRequest(
|
||||||
@@ -78,6 +80,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
activeInstance = this
|
activeInstance = this
|
||||||
isConnected = true
|
isConnected = true
|
||||||
|
lastConnectedAt = System.currentTimeMillis()
|
||||||
|
RecognitionConnectionStore.markConnected(this, lastConnectedAt)
|
||||||
|
RecognitionKeepAliveService.ensureRunning(this)
|
||||||
Log.i(TAG, "Accessibility recognition service connected")
|
Log.i(TAG, "Accessibility recognition service connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +193,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val observed = PaymentParser.fromAccessibility(
|
val observedOutcome = PaymentParser.fromAccessibilityOutcome(
|
||||||
packageName = recognizedPackage,
|
packageName = recognizedPackage,
|
||||||
text = combined,
|
text = combined,
|
||||||
eventTime = currentEvent.eventTime,
|
eventTime = currentEvent.eventTime,
|
||||||
@@ -196,6 +201,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
expectedType = status.direction ?: existingFlow.expectedType,
|
expectedType = status.direction ?: existingFlow.expectedType,
|
||||||
flowKind = currentKind,
|
flowKind = currentKind,
|
||||||
)
|
)
|
||||||
|
val observed = observedOutcome.signal
|
||||||
val resultChanged = observed?.resultFingerprint != null &&
|
val resultChanged = observed?.resultFingerprint != null &&
|
||||||
existingFlow.resultFingerprint != null &&
|
existingFlow.resultFingerprint != null &&
|
||||||
observed.resultFingerprint != existingFlow.resultFingerprint
|
observed.resultFingerprint != existingFlow.resultFingerprint
|
||||||
@@ -229,6 +235,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
nextFlow,
|
nextFlow,
|
||||||
page.nodeCount,
|
page.nodeCount,
|
||||||
"tree",
|
"tree",
|
||||||
|
parseOutcome = observedOutcome,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -267,13 +274,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
currentEvent.windowId,
|
currentEvent.windowId,
|
||||||
now,
|
now,
|
||||||
forceNew = existingFlow?.completed == true ||
|
forceNew = existingFlow?.completed == true ||
|
||||||
|
existingFlow?.committedAt != null ||
|
||||||
existingFlow?.packageName != recognizedPackage,
|
existingFlow?.packageName != recognizedPackage,
|
||||||
kind = inferredKind,
|
kind = inferredKind,
|
||||||
)
|
)
|
||||||
else -> existingFlow?.takeIf { !it.completed }
|
else -> existingFlow?.takeIf { !it.completed }
|
||||||
}
|
}
|
||||||
if (armedFlow != null) {
|
if (armedFlow != null) {
|
||||||
updateFlowEvidence(armedFlow, combined)
|
updateFlowEvidence(armedFlow, combined, eventText, now)
|
||||||
if (clickedPaymentAction) {
|
if (clickedPaymentAction) {
|
||||||
armedFlow.committedAt = armedFlow.committedAt ?: now
|
armedFlow.committedAt = armedFlow.committedAt ?: now
|
||||||
armedFlow.expectedType = armedFlow.expectedType ?: "expense"
|
armedFlow.expectedType = armedFlow.expectedType ?: "expense"
|
||||||
@@ -284,19 +292,28 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val directFlow = paymentFlow?.takeIf { !it.completed }
|
val directFlow = paymentFlow?.takeIf { !it.completed }
|
||||||
val directSignal = if (combined.isBlank()) null else PaymentParser.fromAccessibility(
|
val directOutcome = if (combined.isBlank()) null else {
|
||||||
packageName = recognizedPackage,
|
PaymentParser.fromAccessibilityOutcome(
|
||||||
text = combined,
|
packageName = recognizedPackage,
|
||||||
eventTime = currentEvent.eventTime,
|
text = combined,
|
||||||
windowId = currentEvent.windowId,
|
eventTime = currentEvent.eventTime,
|
||||||
flowSessionId = directFlow?.id,
|
windowId = currentEvent.windowId,
|
||||||
trustedFlow = directFlow?.trusted == true,
|
flowSessionId = directFlow?.id,
|
||||||
expectedAmountCents = directFlow?.expectedAmountCents,
|
trustedFlow = directFlow?.trusted == true,
|
||||||
expectedType = directFlow?.expectedType,
|
expectedAmountEvidence = directFlow?.expectedAmountEvidence?.takeIf {
|
||||||
resultTransitionObserved = directFlow?.resultTransitionObserved == true,
|
it.belongsTo(
|
||||||
submittedFlow = directFlow?.committedAt != null,
|
directFlow.id,
|
||||||
flowKind = directFlow?.kind,
|
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) {
|
if (directSignal != null) {
|
||||||
val flow = directFlow ?: armPaymentFlow(
|
val flow = directFlow ?: armPaymentFlow(
|
||||||
recognizedPackage,
|
recognizedPackage,
|
||||||
@@ -313,6 +330,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
flow,
|
flow,
|
||||||
page.nodeCount,
|
page.nodeCount,
|
||||||
"tree",
|
"tree",
|
||||||
|
parseOutcome = directOutcome,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -350,9 +368,12 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
private fun disconnect() {
|
private fun disconnect() {
|
||||||
if (activeInstance === this) activeInstance = null
|
if (activeInstance === this) activeInstance = null
|
||||||
isConnected = false
|
isConnected = false
|
||||||
|
lastDisconnectedAt = System.currentTimeMillis()
|
||||||
|
RecognitionConnectionStore.markDisconnected(this, lastDisconnectedAt)
|
||||||
pendingRetry = null
|
pendingRetry = null
|
||||||
retryTimeout = null
|
retryTimeout = null
|
||||||
pendingVisualCapture = null
|
pendingVisualCapture = null
|
||||||
|
pendingVisualCaptureAt = 0L
|
||||||
visualTimeout?.let(handler::removeCallbacks)
|
visualTimeout?.let(handler::removeCallbacks)
|
||||||
visualTimeout = null
|
visualTimeout = null
|
||||||
batchCaptureTimeout?.let(handler::removeCallbacks)
|
batchCaptureTimeout?.let(handler::removeCallbacks)
|
||||||
@@ -411,13 +432,35 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
).also { paymentFlow = it }
|
).also { paymentFlow = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateFlowEvidence(flow: PaymentFlow, text: String) {
|
private fun updateFlowEvidence(
|
||||||
if (text.isBlank() || flow.completed || flow.resultTransitionObserved) return
|
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
|
if (PaymentParser.detectStatus(text).strength != PaymentStatusStrength.NONE) return
|
||||||
val pageHash = PaymentParser.sha256(text)
|
val pageHash = PaymentParser.sha256(text)
|
||||||
flow.originPageHash = flow.originPageHash ?: pageHash
|
flow.originPageHash = flow.originPageHash ?: pageHash
|
||||||
flow.expectedAmountCents = flow.expectedAmountCents
|
val observed = PaymentParser.expectedAmountEvidence(
|
||||||
?: PaymentParser.uniqueAmountCents(text)
|
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
|
flow.expectedType = flow.expectedType
|
||||||
?: PaymentParser.inferContextDirection(text)
|
?: PaymentParser.inferContextDirection(text)
|
||||||
PaymentParser.detectFlowKind(text)?.let { detected ->
|
PaymentParser.detectFlowKind(text)?.let { detected ->
|
||||||
@@ -455,9 +498,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
private fun expirePaymentFlow(now: Long) {
|
private fun expirePaymentFlow(now: Long) {
|
||||||
val flow = paymentFlow ?: return
|
val flow = paymentFlow ?: return
|
||||||
if (now - flow.startedAt > PAYMENT_FLOW_TTL_MS) {
|
if (now - flow.startedAt > PAYMENT_FLOW_TTL_MS) {
|
||||||
|
if (flow.committedAt != null && !flow.completed && !flow.trackingTimedOut) {
|
||||||
|
recordResultPageTimeout(flow, 0)
|
||||||
|
}
|
||||||
paymentFlow = null
|
paymentFlow = null
|
||||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||||
pendingVisualCapture = null
|
pendingVisualCapture = null
|
||||||
|
pendingVisualCaptureAt = 0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +515,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
stage: String,
|
stage: String,
|
||||||
recordDiagnostic: Boolean = true,
|
recordDiagnostic: Boolean = true,
|
||||||
evidenceImage: ByteArray? = null,
|
evidenceImage: ByteArray? = null,
|
||||||
|
parseOutcome: PaymentParseOutcome? = null,
|
||||||
) {
|
) {
|
||||||
if (flow.completed) {
|
if (flow.completed) {
|
||||||
evidenceImage?.fill(0)
|
evidenceImage?.fill(0)
|
||||||
@@ -476,6 +524,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
flow.completed = true
|
flow.completed = true
|
||||||
flow.completedAt = System.currentTimeMillis()
|
flow.completedAt = System.currentTimeMillis()
|
||||||
flow.resultFingerprint = signal.resultFingerprint
|
flow.resultFingerprint = signal.resultFingerprint
|
||||||
|
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||||
|
pendingVisualCapture = null
|
||||||
|
pendingVisualCaptureAt = 0L
|
||||||
val coordinator = RecognitionCoordinator.get(this)
|
val coordinator = RecognitionCoordinator.get(this)
|
||||||
val settings = RecognitionSettings.snapshot(this)
|
val settings = RecognitionSettings.snapshot(this)
|
||||||
val batchEnabled = settings.aiScreenshot && settings.aiAllowed && settings.hasAccount
|
val batchEnabled = settings.aiScreenshot && settings.aiAllowed && settings.hasAccount
|
||||||
@@ -503,11 +554,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
"confirm"
|
"confirm"
|
||||||
},
|
},
|
||||||
nodeCount = nodeCount,
|
nodeCount = nodeCount,
|
||||||
amountCandidates = 1,
|
amountCandidates = parseOutcome?.amountCandidateCount
|
||||||
reason = if (batchEnabled) "queued_for_ai" else "success",
|
?: signal.amountCandidateCount,
|
||||||
expectedAmountMatched = flow.expectedAmountCents?.let {
|
reason = signal.amountIssueReason
|
||||||
it == signal.amountCents
|
?: parseOutcome?.reason
|
||||||
},
|
?: if (batchEnabled) "queued_for_ai" else "success",
|
||||||
|
expectedAmountMatched = parseOutcome?.expectedAmountMatched
|
||||||
|
?: signal.expectedAmountMatched,
|
||||||
resultTransitionObserved = flow.resultTransitionObserved,
|
resultTransitionObserved = flow.resultTransitionObserved,
|
||||||
recognitionKind = signal.recognitionKind,
|
recognitionKind = signal.recognitionKind,
|
||||||
amountSource = signal.amountSource,
|
amountSource = signal.amountSource,
|
||||||
@@ -524,22 +577,58 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
) {
|
) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
|
||||||
flow.completed ||
|
flow.completed ||
|
||||||
flow.probeCount >= MAX_VISUAL_PROBES ||
|
flow.trackingTimedOut ||
|
||||||
ocrInProgress
|
flow.probeCount >= MAX_VISUAL_PROBES
|
||||||
) return
|
) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - flow.startedAt >= PAYMENT_FLOW_TTL_MS) {
|
||||||
|
recordResultPageTimeout(flow, nodeCount)
|
||||||
|
return
|
||||||
|
}
|
||||||
val throttleWait = (
|
val throttleWait = (
|
||||||
VISUAL_CAPTURE_THROTTLE_MS - (now - lastVisualCaptureAt)
|
VISUAL_CAPTURE_THROTTLE_MS - (now - lastVisualCaptureAt)
|
||||||
).coerceAtLeast(0L)
|
).coerceAtLeast(0L)
|
||||||
|
val targetAt = now + maxOf(delayMs, throttleWait)
|
||||||
|
if (pendingVisualCapture != null &&
|
||||||
|
pendingVisualCaptureAt > 0L &&
|
||||||
|
pendingVisualCaptureAt <= targetAt
|
||||||
|
) return
|
||||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||||
pendingVisualCapture = Runnable {
|
pendingVisualCapture = Runnable {
|
||||||
pendingVisualCapture = null
|
pendingVisualCapture = null
|
||||||
|
pendingVisualCaptureAt = 0L
|
||||||
val current = paymentFlow
|
val current = paymentFlow
|
||||||
if (current?.id != flow.id || current.completed ||
|
if (current?.id != flow.id || current.completed || current.trackingTimedOut) {
|
||||||
lastPackageName != flow.packageName
|
return@Runnable
|
||||||
) 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)
|
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) {
|
private fun captureForLocalOcr(flow: PaymentFlow, reason: String, nodeCount: Int) {
|
||||||
@@ -659,10 +748,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
val recognitionSettings = RecognitionSettings.snapshot(
|
val recognitionSettings = RecognitionSettings.snapshot(
|
||||||
this@ScreenshotAccessibilityService,
|
this@ScreenshotAccessibilityService,
|
||||||
)
|
)
|
||||||
val visualTransitionObserved = flow.resultTransitionObserved ||
|
val visualTransitionObserved = flow.resultTransitionObserved
|
||||||
flow.committedAt?.let {
|
|
||||||
capturedAt - it >= VISUAL_STABILITY_DELAY_MS
|
|
||||||
} == true
|
|
||||||
runCatching {
|
runCatching {
|
||||||
LocalPaymentOcr.analyze(
|
LocalPaymentOcr.analyze(
|
||||||
context = this@ScreenshotAccessibilityService,
|
context = this@ScreenshotAccessibilityService,
|
||||||
@@ -671,7 +757,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
flowSessionId = flow.id,
|
flowSessionId = flow.id,
|
||||||
trustedFlow = flow.trusted,
|
trustedFlow = flow.trusted,
|
||||||
capturedAt = capturedAt,
|
capturedAt = capturedAt,
|
||||||
expectedAmountCents = flow.expectedAmountCents,
|
expectedAmountEvidence = flow.expectedAmountEvidence?.takeIf {
|
||||||
|
it.belongsTo(flow.id, flow.startedAt, flow.committedAt)
|
||||||
|
},
|
||||||
expectedType = flow.expectedType,
|
expectedType = flow.expectedType,
|
||||||
resultTransitionObserved = visualTransitionObserved,
|
resultTransitionObserved = visualTransitionObserved,
|
||||||
submittedFlow = flow.committedAt != null,
|
submittedFlow = flow.committedAt != null,
|
||||||
@@ -773,15 +861,30 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
nodeCount: Int,
|
nodeCount: Int,
|
||||||
) {
|
) {
|
||||||
try {
|
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(
|
RecognitionDiagnostics.record(
|
||||||
this,
|
this,
|
||||||
flow.packageName,
|
flow.packageName,
|
||||||
stage = "ocr",
|
stage = "ocr",
|
||||||
result = if (outcome.signal != null) "matched" else "rejected",
|
result = when {
|
||||||
|
outcome.signal != null -> "matched"
|
||||||
|
shouldWait -> "waiting"
|
||||||
|
else -> "rejected"
|
||||||
|
},
|
||||||
nodeCount = nodeCount,
|
nodeCount = nodeCount,
|
||||||
ocrMs = outcome.latencyMs,
|
ocrMs = outcome.latencyMs,
|
||||||
amountCandidates = outcome.amountCandidateCount,
|
amountCandidates = outcome.amountCandidateCount,
|
||||||
reason = outcome.reason,
|
reason = diagnosticReason,
|
||||||
statusStrength = outcome.statusStrength,
|
statusStrength = outcome.statusStrength,
|
||||||
expectedAmountMatched = outcome.expectedAmountMatched,
|
expectedAmountMatched = outcome.expectedAmountMatched,
|
||||||
resultTransitionObserved = outcome.resultTransitionObserved,
|
resultTransitionObserved = outcome.resultTransitionObserved,
|
||||||
@@ -812,17 +915,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val retryable = isRetryableOcrOutcome(outcome.reason)
|
if (shouldWait) {
|
||||||
if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) {
|
|
||||||
flow.retryCount += 1
|
flow.retryCount += 1
|
||||||
scheduleVisualRecognition(
|
scheduleVisualRecognition(
|
||||||
flow,
|
flow,
|
||||||
reason = "ocr_retry",
|
reason = "result_page_follow_up",
|
||||||
nodeCount = nodeCount,
|
nodeCount = nodeCount,
|
||||||
delayMs = VISUAL_RETRY_DELAY_MS,
|
delayMs = resultProbeDelayAfterCapture(flow.probeCount),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (retryable) flow.trackingTimedOut = true
|
||||||
if (outcome.sawSuccess) maybeUseAiFallback(flow, bitmap)
|
if (outcome.sawSuccess) maybeUseAiFallback(flow, bitmap)
|
||||||
} finally {
|
} finally {
|
||||||
bitmap.recycle()
|
bitmap.recycle()
|
||||||
@@ -925,17 +1028,51 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
nodeCount = nodeCount,
|
nodeCount = nodeCount,
|
||||||
reason = reason,
|
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
|
flow.retryCount += 1
|
||||||
|
RecognitionDiagnostics.record(
|
||||||
|
this,
|
||||||
|
flow.packageName,
|
||||||
|
stage = "capture",
|
||||||
|
result = "waiting",
|
||||||
|
nodeCount = nodeCount,
|
||||||
|
reason = reason,
|
||||||
|
)
|
||||||
scheduleVisualRecognition(
|
scheduleVisualRecognition(
|
||||||
flow,
|
flow,
|
||||||
reason = "capture_retry",
|
reason = "capture_retry",
|
||||||
nodeCount = nodeCount,
|
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 =
|
private fun shouldFallbackToDisplay(errorCode: Int): Boolean =
|
||||||
errorCode == ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR ||
|
errorCode == ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR ||
|
||||||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
|
(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 MIN_RESULT_TRANSITION_DELAY_MS = 250L
|
||||||
private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L
|
private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L
|
||||||
private const val VISUAL_STABILITY_DELAY_MS = 700L
|
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 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 CAPTURE_CALLBACK_TIMEOUT_MS = 6_000L
|
||||||
private const val OCR_CALLBACK_TIMEOUT_MS = 12_000L
|
private const val OCR_CALLBACK_TIMEOUT_MS = 12_000L
|
||||||
private const val STALE_BITMAP_RELEASE_DELAY_MS = 60_000L
|
private const val STALE_BITMAP_RELEASE_DELAY_MS = 60_000L
|
||||||
private const val WINDOW_CAPTURE_FALLBACK_DELAY_MS = 450L
|
private const val WINDOW_CAPTURE_FALLBACK_DELAY_MS = 450L
|
||||||
private const val MAX_VISUAL_RETRIES = 1
|
private const val MAX_VISUAL_PROBES = 5
|
||||||
private const val MAX_VISUAL_PROBES = 4
|
|
||||||
private const val MAX_TREE_NODES = 160
|
private const val MAX_TREE_NODES = 160
|
||||||
private const val MAX_CHILDREN_PER_NODE = 40
|
private const val MAX_CHILDREN_PER_NODE = 40
|
||||||
private const val MAX_TEXT_CHARS = 8_000
|
private const val MAX_TEXT_CHARS = 8_000
|
||||||
@@ -1198,6 +1335,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED,
|
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED,
|
||||||
AccessibilityEvent.TYPE_VIEW_CLICKED,
|
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
|
@Volatile
|
||||||
private var activeInstance: ScreenshotAccessibilityService? = null
|
private var activeInstance: ScreenshotAccessibilityService? = null
|
||||||
@@ -1213,6 +1357,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
"payment_input_page",
|
"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(
|
internal fun completedResultStartReason(
|
||||||
hasObservedResult: Boolean,
|
hasObservedResult: Boolean,
|
||||||
resultFingerprintChanged: Boolean,
|
resultFingerprintChanged: Boolean,
|
||||||
@@ -1230,6 +1385,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
var isConnected = false
|
var isConnected = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastConnectedAt = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastDisconnectedAt = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
fun requestScreenshot(
|
fun requestScreenshot(
|
||||||
showResult: Boolean,
|
showResult: Boolean,
|
||||||
delayMs: Long = 0L,
|
delayMs: Long = 0L,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class PaymentParserTest {
|
|||||||
windowId = 7,
|
windowId = 7,
|
||||||
flowSessionId = "flow-a",
|
flowSessionId = "flow-a",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 2_000L,
|
expectedAmountEvidence = expectedEvidence(2_000L, "strong", "flow-a"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
submittedFlow = true,
|
submittedFlow = true,
|
||||||
@@ -107,7 +107,7 @@ class PaymentParserTest {
|
|||||||
windowId = 7,
|
windowId = 7,
|
||||||
flowSessionId = "flow-b",
|
flowSessionId = "flow-b",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 3_000L,
|
expectedAmountEvidence = expectedEvidence(3_000L, "strong", "flow-b"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
)
|
)
|
||||||
@@ -117,10 +117,46 @@ class PaymentParserTest {
|
|||||||
@Test
|
@Test
|
||||||
fun paymentInputAndAmbiguousAmountsAreRejected() {
|
fun paymentInputAndAmbiguousAmountsAreRejected() {
|
||||||
assertTrue(PaymentParser.isPaymentInputPage("请输入支付密码\n确认转账"))
|
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"))
|
assertEquals(2_000L, PaymentParser.uniqueAmountCents("付款金额 ¥20.00\n¥20.00"))
|
||||||
assertNull(PaymentParser.uniqueAmountCents("¥20.00\n优惠 ¥2.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
|
@Test
|
||||||
fun diagnosticPreviewMasksSensitiveValuesAndLimitsLines() {
|
fun diagnosticPreviewMasksSensitiveValuesAndLimitsLines() {
|
||||||
val preview = OcrDiagnosticRedactor.redact(
|
val preview = OcrDiagnosticRedactor.redact(
|
||||||
@@ -149,7 +185,7 @@ class PaymentParserTest {
|
|||||||
windowId = 8,
|
windowId = 8,
|
||||||
flowSessionId = "payment-flow",
|
flowSessionId = "payment-flow",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 1_880L,
|
expectedAmountEvidence = expectedEvidence(1_880L, "strong", "payment-flow"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
submittedFlow = true,
|
submittedFlow = true,
|
||||||
@@ -171,7 +207,7 @@ class PaymentParserTest {
|
|||||||
windowId = 8,
|
windowId = 8,
|
||||||
flowSessionId = "payment-flow",
|
flowSessionId = "payment-flow",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 1_880L,
|
expectedAmountEvidence = expectedEvidence(1_880L, "strong", "payment-flow"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
submittedFlow = false,
|
submittedFlow = false,
|
||||||
@@ -190,7 +226,7 @@ class PaymentParserTest {
|
|||||||
windowId = 9,
|
windowId = 9,
|
||||||
flowSessionId = "red-packet-flow",
|
flowSessionId = "red-packet-flow",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 2_000L,
|
expectedAmountEvidence = expectedEvidence(2_000L, "strong", "red-packet-flow"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
submittedFlow = 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(
|
private fun paymentSignal(
|
||||||
channel: String,
|
channel: String,
|
||||||
sourceEventId: String,
|
sourceEventId: String,
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ final router = GoRouter(
|
|||||||
refreshListenable: SessionStore.instance,
|
refreshListenable: SessionStore.instance,
|
||||||
redirect: (_, state) {
|
redirect: (_, state) {
|
||||||
final aiOnly =
|
final aiOnly =
|
||||||
state.matchedLocation == '/chat' ||
|
|
||||||
state.matchedLocation == '/ai-mode' ||
|
state.matchedLocation == '/ai-mode' ||
|
||||||
state.matchedLocation == '/companion';
|
state.matchedLocation == '/companion';
|
||||||
if (aiOnly &&
|
if (aiOnly &&
|
||||||
@@ -86,7 +85,15 @@ final router = GoRouter(
|
|||||||
path: '/categories',
|
path: '/categories',
|
||||||
builder: (_, __) => const CategoryManagePage(),
|
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(
|
GoRoute(
|
||||||
path: '/legal/:kind',
|
path: '/legal/:kind',
|
||||||
builder: (_, state) => LegalDocumentPage(
|
builder: (_, state) => LegalDocumentPage(
|
||||||
@@ -175,8 +182,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
|||||||
if (SessionStore.instance.hasSession) {
|
if (SessionStore.instance.hasSession) {
|
||||||
await _runSafely(CurrentLedgerStore.instance.loadCached);
|
await _runSafely(CurrentLedgerStore.instance.loadCached);
|
||||||
}
|
}
|
||||||
await _runSafely(RecognitionImportService.configureNativeContext);
|
await _runSafely(_restoreRecognitionServices);
|
||||||
await _runSafely(RecognitionImportService.importAutomatic);
|
|
||||||
await _runSafely(PushService.instance.initialize);
|
await _runSafely(PushService.instance.initialize);
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
unawaited(_refreshRemoteState());
|
unawaited(_refreshRemoteState());
|
||||||
@@ -184,12 +190,25 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Future<void> _resumeServices() async {
|
Future<void> _resumeServices() async {
|
||||||
unawaited(ApiClient.instance.probe());
|
unawaited(ApiClient.instance.probe());
|
||||||
await _runSafely(RecognitionImportService.configureNativeContext);
|
await _runSafely(_restoreRecognitionServices);
|
||||||
await _runSafely(RecognitionImportService.importAutomatic);
|
|
||||||
await _runSafely(PushService.instance.refresh);
|
await _runSafely(PushService.instance.refresh);
|
||||||
unawaited(_refreshRemoteState());
|
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 {
|
Future<void> _refreshRemoteState() async {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_runSafely(PublicConfigApi.init),
|
_runSafely(PublicConfigApi.init),
|
||||||
@@ -220,6 +239,10 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
|||||||
await Future<void>.delayed(const Duration(milliseconds: 180));
|
await Future<void>.delayed(const Duration(milliseconds: 180));
|
||||||
final context = _rootNavigatorKey.currentContext;
|
final context = _rootNavigatorKey.currentContext;
|
||||||
if (!mounted || context == null || !context.mounted) return;
|
if (!mounted || context == null || !context.mounted) return;
|
||||||
|
if (action['action'] == 'open_recognition_settings') {
|
||||||
|
router.push('/screenshot-settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await RecognitionImportService.handleAction(context, action);
|
await RecognitionImportService.handleAction(context, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ class _AddPageState extends State<AddPage> {
|
|||||||
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
|
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
|
||||||
paymentMethod: _paymentMethod,
|
paymentMethod: _paymentMethod,
|
||||||
occurredAt: _occurredAt,
|
occurredAt: _occurredAt,
|
||||||
|
localFirst: true,
|
||||||
);
|
);
|
||||||
TransactionEvents.notifyChanged();
|
TransactionEvents.notifyChanged();
|
||||||
if (mounted) context.pop(amount);
|
if (mounted) context.pop(amount);
|
||||||
|
|||||||
@@ -743,7 +743,8 @@ class _ChatPageState extends State<ChatPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _inputBar(BuildContext context) {
|
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;
|
final accent = widget.aiMode ? AppTheme.ai : AppTheme.primary;
|
||||||
return Container(
|
return Container(
|
||||||
color: context.jz.card,
|
color: context.jz.card,
|
||||||
@@ -866,23 +867,59 @@ class _ChatPageState extends State<ChatPage> {
|
|||||||
),
|
),
|
||||||
SizedBox(width: 6),
|
SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
SizedBox(
|
AnimatedSize(
|
||||||
width: 50,
|
duration: const Duration(milliseconds: 180),
|
||||||
height: 38,
|
curve: Curves.easeOutCubic,
|
||||||
child: FilledButton(
|
child: AnimatedSwitcher(
|
||||||
onPressed: sendEnabled ? _send : null,
|
duration: const Duration(milliseconds: 160),
|
||||||
style: FilledButton.styleFrom(
|
transitionBuilder: (child, animation) => FadeTransition(
|
||||||
padding: EdgeInsets.zero,
|
opacity: animation,
|
||||||
backgroundColor: accent,
|
child: ScaleTransition(
|
||||||
disabledBackgroundColor: context.jz.line,
|
scale: Tween<double>(begin: 0.9, end: 1).animate(animation),
|
||||||
shape: RoundedRectangleBorder(
|
child: child,
|
||||||
borderRadius: BorderRadius.circular(11),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: hasText
|
||||||
'发送',
|
? SizedBox(
|
||||||
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700),
|
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) {
|
final status = switch (accessState) {
|
||||||
AiAccessState.guest => '登录后可用',
|
AiAccessState.guest => '登录后可用',
|
||||||
AiAccessState.reauthenticate => '需要重新登录',
|
AiAccessState.reauthenticate => '需要重新登录',
|
||||||
|
AiAccessState.aiDisabled => 'AI 功能已关闭',
|
||||||
AiAccessState.cloudDisabled => '云连接已关闭',
|
AiAccessState.cloudDisabled => '云连接已关闭',
|
||||||
null => '在线',
|
null => '在线',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,14 +25,6 @@ class _MainShellState extends State<MainShell> {
|
|||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: SessionStore.instance,
|
animation: SessionStore.instance,
|
||||||
builder: (context, _) {
|
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(
|
return Scaffold(
|
||||||
body: AnimatedSwitcher(
|
body: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
@@ -58,12 +50,11 @@ class _MainShellState extends State<MainShell> {
|
|||||||
_tab('明细', AppIcons.home, 0),
|
_tab('明细', AppIcons.home, 0),
|
||||||
_tab('统计', AppIcons.chart, 1),
|
_tab('统计', AppIcons.chart, 1),
|
||||||
SizedBox(width: 72, child: _fab()),
|
SizedBox(width: 72, child: _fab()),
|
||||||
if (showAiEntry)
|
ValueListenableBuilder<CompanionDisplay>(
|
||||||
ValueListenableBuilder<CompanionDisplay>(
|
valueListenable: PublicConfigApi.companionNotifier,
|
||||||
valueListenable: PublicConfigApi.companionNotifier,
|
builder: (_, companion, __) =>
|
||||||
builder: (_, companion, __) =>
|
_tab(companion.name, AppIcons.chat, 2),
|
||||||
_tab(companion.name, AppIcons.chat, 2),
|
),
|
||||||
),
|
|
||||||
_tab('我的', AppIcons.user, 3),
|
_tab('我的', AppIcons.user, 3),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -31,37 +31,42 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadConfig() async {
|
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 {
|
try {
|
||||||
final avatars = await PublicConfigApi.avatars();
|
return 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;
|
|
||||||
});
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// API 失败用硬编码兜底
|
return const [];
|
||||||
if (mounted) setState(() => _loaded = true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底数据(API 不可用时)
|
Future<List<PersonaItem>> _loadPersonas() async {
|
||||||
static const _fallbackAvatars = [
|
try {
|
||||||
('cat', '小账喵', AppIcons.cat),
|
return await PublicConfigApi.personas();
|
||||||
('dog', '阿福汪', AppIcons.dog),
|
} catch (_) {
|
||||||
('robot', '账小智', AppIcons.robot),
|
return const [];
|
||||||
];
|
}
|
||||||
static const _fallbackPersonas = [
|
}
|
||||||
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽'),
|
|
||||||
('gentle', '温柔小暖', '永远鼓励,温柔提醒'),
|
|
||||||
('strict', '严格管家', '理性专业,数据说话'),
|
|
||||||
('meme', '沙雕损友', '玩梗高手,快乐记账'),
|
|
||||||
];
|
|
||||||
|
|
||||||
Future<void> _finish() async {
|
Future<void> _finish() async {
|
||||||
|
if (!_hasValidCatalogSelection) return;
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
await AuthApi.completeOnboarding(
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_loaded)
|
if (!_loaded)
|
||||||
@@ -170,9 +179,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _CompanionStep() {
|
Widget _CompanionStep() {
|
||||||
// 优先 API 数据,回退硬编码
|
final avatars = _avatars
|
||||||
final avatars = _avatars.isNotEmpty
|
|
||||||
? _avatars
|
|
||||||
.map(
|
.map(
|
||||||
(a) => (
|
(a) => (
|
||||||
a.key,
|
a.key,
|
||||||
@@ -184,11 +191,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
: AppIcons.cat,
|
: AppIcons.cat,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList()
|
.toList();
|
||||||
: _fallbackAvatars;
|
final personas = _personas
|
||||||
final personas = _personas.isNotEmpty
|
.map((p) => (p.key, p.name, p.description))
|
||||||
? _personas.map((p) => (p.key, p.name, p.description)).toList()
|
.toList();
|
||||||
: _fallbackPersonas;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -226,6 +232,35 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
style: TextStyle(fontSize: 12, color: context.jz.text2),
|
style: TextStyle(fontSize: 12, color: context.jz.text2),
|
||||||
),
|
),
|
||||||
SizedBox(height: 14),
|
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(
|
SizedBox(
|
||||||
height: 96,
|
height: 96,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -304,7 +339,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: on ? context.jz.aiBackground : Colors.white,
|
color: on ? context.jz.aiBackground : context.jz.card,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: on ? AppTheme.ai : context.jz.line,
|
color: on ? AppTheme.ai : context.jz.line,
|
||||||
@@ -339,11 +374,12 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
|
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
|
||||||
onPressed: _saving ? null : _finish,
|
onPressed: _saving || !_hasValidCatalogSelection ? null : _finish,
|
||||||
child: _saving
|
child: _saving
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
|
|||||||
@@ -65,7 +65,13 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
|||||||
final result = await _showEditor();
|
final result = await _showEditor();
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
try {
|
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();
|
await _load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mounted) _showError(error);
|
if (mounted) _showError(error);
|
||||||
@@ -82,6 +88,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
|||||||
iconKey: result.icon,
|
iconKey: result.icon,
|
||||||
colorKey: result.color,
|
colorKey: result.color,
|
||||||
sortOrder: category.sortOrder,
|
sortOrder: category.sortOrder,
|
||||||
|
localFirst: true,
|
||||||
);
|
);
|
||||||
await _load();
|
await _load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -375,7 +382,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
|||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
await CategoryApi.delete(category.id);
|
await CategoryApi.delete(category.id, localFirst: true);
|
||||||
await _load();
|
await _load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mounted) _showError(error);
|
if (mounted) _showError(error);
|
||||||
@@ -410,7 +417,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
|||||||
final ids = _custom.map((category) => category.id).toList();
|
final ids = _custom.map((category) => category.id).toList();
|
||||||
setState(() => _savingOrder = true);
|
setState(() => _savingOrder = true);
|
||||||
try {
|
try {
|
||||||
await CategoryApi.reorder(_type, ids);
|
await CategoryApi.reorder(_type, ids, localFirst: true);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_reordering = false;
|
_reordering = false;
|
||||||
|
|||||||
@@ -264,13 +264,6 @@ class _MePageState extends State<MePage> {
|
|||||||
'预算管理',
|
'预算管理',
|
||||||
onTap: () => context.push('/budget'),
|
onTap: () => context.push('/budget'),
|
||||||
),
|
),
|
||||||
_row(
|
|
||||||
AppIcons.avatarAsset(PublicConfigApi.companionAvatarKey),
|
|
||||||
context.jz.aiBackground,
|
|
||||||
AppTheme.ai,
|
|
||||||
session.aiEnabled ? 'AI 报告' : '报告',
|
|
||||||
onTap: () => context.push('/report'),
|
|
||||||
),
|
|
||||||
_row(
|
_row(
|
||||||
AppIcons.tag,
|
AppIcons.tag,
|
||||||
context.jz.primaryBackground,
|
context.jz.primaryBackground,
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
Timer? _diagnosticRefreshTimer;
|
Timer? _diagnosticRefreshTimer;
|
||||||
Timer? _previewExpiryTimer;
|
Timer? _previewExpiryTimer;
|
||||||
bool _diagnosticRefreshInFlight = false;
|
bool _diagnosticRefreshInFlight = false;
|
||||||
|
bool _connectionCheckInFlight = false;
|
||||||
|
bool _keepAliveStarting = false;
|
||||||
|
bool _recentsProtectionNoticeChecked = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -60,14 +63,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
void _refreshRunningDiagnostic() {
|
void _refreshRunningDiagnostic() {
|
||||||
if (!mounted ||
|
if (!mounted ||
|
||||||
_diagnosticRefreshInFlight ||
|
_diagnosticRefreshInFlight ||
|
||||||
_status?.latestDiagnostic?.result != 'started') {
|
!{'started', 'waiting'}.contains(_status?.latestDiagnostic?.result)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_diagnosticRefreshInFlight = true;
|
_diagnosticRefreshInFlight = true;
|
||||||
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
|
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _check() async {
|
Future<void> _check({bool monitorConnection = true}) async {
|
||||||
try {
|
try {
|
||||||
var status = await ScreenshotChannel.recognitionStatus();
|
var status = await ScreenshotChannel.recognitionStatus();
|
||||||
final invalid = <String>[
|
final invalid = <String>[
|
||||||
@@ -97,6 +100,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
_schedulePreviewExpiry(status);
|
_schedulePreviewExpiry(status);
|
||||||
|
unawaited(_showRecentsProtectionNotice(status));
|
||||||
|
if (monitorConnection &&
|
||||||
|
status.accessibilityAuthorized &&
|
||||||
|
!status.accessibilityConnected &&
|
||||||
|
!status.taskCleanerRecoveryNeeded &&
|
||||||
|
(status.accessibilityEvents || status.aiScreenshot)) {
|
||||||
|
unawaited(_monitorAccessibilityConnection());
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
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) {
|
void _schedulePreviewExpiry(RecognitionStatus status) {
|
||||||
_previewExpiryTimer?.cancel();
|
_previewExpiryTimer?.cancel();
|
||||||
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
|
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
|
||||||
@@ -431,7 +485,32 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
|
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
|
||||||
authorized: status!.accessibilityAuthorized,
|
authorized: status!.accessibilityAuthorized,
|
||||||
connected: status.accessibilityConnected,
|
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,
|
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
|
||||||
|
settingsLabel: '前往无障碍设置',
|
||||||
|
onRetry: status.accessibilityNeedsRecovery
|
||||||
|
? () => _monitorAccessibilityConnection()
|
||||||
|
: null,
|
||||||
|
onStartKeepAlive:
|
||||||
|
(status.accessibilityEvents || status.aiScreenshot) &&
|
||||||
|
status.keepAliveNeedsRecovery &&
|
||||||
|
!_keepAliveStarting
|
||||||
|
? () =>
|
||||||
|
_monitorAccessibilityConnection(ensureKeepAlive: true)
|
||||||
|
: null,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
JzSwitchTile(
|
JzSwitchTile(
|
||||||
@@ -538,7 +617,12 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
status.notificationEvents ||
|
status.notificationEvents ||
|
||||||
status.aiScreenshot) ...[
|
status.aiScreenshot) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_BackgroundKeepAliveCard(status: status),
|
_BackgroundKeepAliveCard(
|
||||||
|
status: status,
|
||||||
|
starting: _keepAliveStarting,
|
||||||
|
onStart: () =>
|
||||||
|
_monitorAccessibilityConnection(ensureKeepAlive: true),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_RecognitionCard(
|
_RecognitionCard(
|
||||||
@@ -836,8 +920,14 @@ class _EvidencePill extends StatelessWidget {
|
|||||||
|
|
||||||
class _BackgroundKeepAliveCard extends StatelessWidget {
|
class _BackgroundKeepAliveCard extends StatelessWidget {
|
||||||
final RecognitionStatus status;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -875,9 +965,18 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
status.batteryOptimizationIgnored ? '后台限制较少' : '需要设置',
|
status.recentsProtectionActive
|
||||||
|
? '任务清理防护中'
|
||||||
|
: status.keepAliveRunning
|
||||||
|
? '保护运行中'
|
||||||
|
: status.keepAliveExpected
|
||||||
|
? '保护未运行'
|
||||||
|
: status.batteryOptimizationIgnored
|
||||||
|
? '后台限制较少'
|
||||||
|
: '需要设置',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: status.batteryOptimizationIgnored
|
color:
|
||||||
|
status.keepAliveRunning || status.recentsProtectionActive
|
||||||
? AppTheme.primaryDeep
|
? AppTheme.primaryDeep
|
||||||
: AppTheme.orange,
|
: AppTheme.orange,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -888,15 +987,29 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
'无障碍和通知监听由 Android 独立轻量进程运行。请允许记之后台活动,'
|
status.recentsProtectionActive
|
||||||
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
|
? 'OriginOS 最近任务保护已开启。记之不会显示在最近任务中,请从桌面图标重新打开;关闭全部识别后会自动恢复任务卡。'
|
||||||
'否则系统清理进程后可能暂时收不到支付事件。',
|
: '无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
|
||||||
|
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
|
||||||
|
'否则系统清理进程后可能暂时收不到支付事件。',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: context.jz.text2,
|
color: context.jz.text2,
|
||||||
fontSize: 11.5,
|
fontSize: 11.5,
|
||||||
height: 1.55,
|
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),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -932,6 +1045,10 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
final String? statusLabel;
|
final String? statusLabel;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final VoidCallback? onOpenSettings;
|
final VoidCallback? onOpenSettings;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
final VoidCallback? onStartKeepAlive;
|
||||||
|
final String? recoveryMessage;
|
||||||
|
final String settingsLabel;
|
||||||
|
|
||||||
const _RecognitionCard({
|
const _RecognitionCard({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -942,12 +1059,16 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
required this.child,
|
required this.child,
|
||||||
this.statusLabel,
|
this.statusLabel,
|
||||||
this.onOpenSettings,
|
this.onOpenSettings,
|
||||||
|
this.onRetry,
|
||||||
|
this.onStartKeepAlive,
|
||||||
|
this.recoveryMessage,
|
||||||
|
this.settingsLabel = '前往系统设置',
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final palette = context.jz;
|
final palette = context.jz;
|
||||||
final color = connected ? AppTheme.primary : AppTheme.ai;
|
final color = connected ? AppTheme.primary : AppTheme.orange;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -966,7 +1087,7 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: connected
|
color: connected
|
||||||
? palette.primaryBackground
|
? palette.primaryBackground
|
||||||
: palette.aiBackground,
|
: palette.warningBackground,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: color, size: 21),
|
child: Icon(icon, color: color, size: 21),
|
||||||
@@ -986,7 +1107,7 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: connected
|
color: connected
|
||||||
? palette.primaryBackground
|
? palette.primaryBackground
|
||||||
: palette.aiBackground,
|
: palette.warningBackground,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -1010,44 +1131,59 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
description,
|
description,
|
||||||
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
|
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
if (recoveryMessage != null) ...[
|
||||||
child,
|
const SizedBox(height: 9),
|
||||||
if (onOpenSettings != null) ...[
|
Row(
|
||||||
const SizedBox(height: 6),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Align(
|
children: [
|
||||||
alignment: Alignment.centerLeft,
|
const Icon(
|
||||||
child: Semantics(
|
Icons.info_outline_rounded,
|
||||||
button: true,
|
color: AppTheme.orange,
|
||||||
label: '打开系统权限设置',
|
size: 18,
|
||||||
child: InkWell(
|
),
|
||||||
onTap: onOpenSettings,
|
const SizedBox(width: 7),
|
||||||
borderRadius: BorderRadius.circular(10),
|
Expanded(
|
||||||
child: Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.symmetric(
|
recoveryMessage!,
|
||||||
horizontal: 4,
|
style: TextStyle(
|
||||||
vertical: 8,
|
color: palette.text2,
|
||||||
),
|
fontSize: 11.5,
|
||||||
child: Row(
|
height: 1.5,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import 'package:miaoji_zhang/shared/services/session_store.dart';
|
|||||||
enum _ReportKind { weekly, monthly, yearly }
|
enum _ReportKind { weekly, monthly, yearly }
|
||||||
|
|
||||||
class ReportPage extends StatefulWidget {
|
class ReportPage extends StatefulWidget {
|
||||||
const ReportPage({super.key});
|
final String? initialPeriod;
|
||||||
|
final DateTime? initialAnchor;
|
||||||
|
|
||||||
|
const ReportPage({super.key, this.initialPeriod, this.initialAnchor});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ReportPage> createState() => _ReportPageState();
|
State<ReportPage> createState() => _ReportPageState();
|
||||||
@@ -33,6 +36,12 @@ class _ReportPageState extends State<ReportPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_anchor = widget.initialAnchor ?? ShanghaiTime.now;
|
||||||
|
_kind = switch (widget.initialPeriod) {
|
||||||
|
'week' => _ReportKind.weekly,
|
||||||
|
'year' => _ReportKind.yearly,
|
||||||
|
_ => _ReportKind.monthly,
|
||||||
|
};
|
||||||
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
||||||
CurrentLedgerStore.instance.addListener(_load);
|
CurrentLedgerStore.instance.addListener(_load);
|
||||||
unawaited(_load());
|
unawaited(_load());
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import 'dart:async';
|
|||||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.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/api_client.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/business_api.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/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/theme/app_theme.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_controls.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/async_error_view.dart';
|
||||||
@@ -161,8 +164,7 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
else ...[
|
else ...[
|
||||||
_categoryCard(stats),
|
_categoryCard(stats),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
if (stats.analysis case final analysis?)
|
_reportCard(stats.analysis),
|
||||||
_analysisCard(analysis),
|
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
_trendCard(stats),
|
_trendCard(stats),
|
||||||
if (stats.byCategory.isNotEmpty) ...[
|
if (stats.byCategory.isNotEmpty) ...[
|
||||||
@@ -318,41 +320,103 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _analysisCard(String text) {
|
Widget _reportCard(String? analysis) {
|
||||||
return Container(
|
final aiEnabled = SessionStore.instance.aiEnabled;
|
||||||
padding: const EdgeInsets.all(14),
|
final accent = aiEnabled ? AppTheme.ai : AppTheme.primary;
|
||||||
decoration: BoxDecoration(
|
final background = aiEnabled
|
||||||
color: context.jz.aiBackground,
|
? 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),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: AppTheme.ai.withValues(alpha: 0.12)),
|
child: InkWell(
|
||||||
),
|
onTap: () => context.push(route),
|
||||||
child: Row(
|
borderRadius: BorderRadius.circular(14),
|
||||||
children: [
|
child: Container(
|
||||||
Container(
|
padding: const EdgeInsets.all(14),
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.jz.card,
|
borderRadius: BorderRadius.circular(14),
|
||||||
borderRadius: BorderRadius.circular(10),
|
border: Border.all(color: accent.withValues(alpha: 0.12)),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Row(
|
||||||
Icons.auto_awesome_rounded,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
size: 16,
|
children: [
|
||||||
color: AppTheme.ai,
|
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/local_database.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/session_store.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/shanghai_time.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/services/sync_service.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class TxItem {
|
class TxItem {
|
||||||
@@ -370,10 +371,7 @@ class TxApi {
|
|||||||
static Future<List<CategoryItem>> categories(String type) async {
|
static Future<List<CategoryItem>> categories(String type) async {
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly) {
|
if (session.shouldUseLocalOnly) {
|
||||||
return LocalDatabase.instance
|
return categoriesLocal(type);
|
||||||
.categories(type)
|
|
||||||
.map(CategoryItem.fromJson)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
@@ -387,18 +385,17 @@ class TxApi {
|
|||||||
.toList();
|
.toList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||||
return LocalDatabase.instance
|
return categoriesLocal(type);
|
||||||
.categories(type)
|
|
||||||
.map(CategoryItem.fromJson)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<CategoryItem> categoriesLocal(String type) => LocalDatabase
|
static List<CategoryItem> categoriesLocal(String type) {
|
||||||
.instance
|
LocalDatabase.instance.ensureDefaultCategories(type: type);
|
||||||
.categories(type)
|
return LocalDatabase.instance
|
||||||
.map(CategoryItem.fromJson)
|
.categories(type)
|
||||||
.toList();
|
.map(CategoryItem.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<CategoryItem>> categoriesRemote(String type) async {
|
static Future<List<CategoryItem>> categoriesRemote(String type) async {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
@@ -407,8 +404,10 @@ class TxApi {
|
|||||||
);
|
);
|
||||||
final values = response.data as List;
|
final values = response.data as List;
|
||||||
LocalDatabase.instance.cacheCategories(values, replaceType: type);
|
LocalDatabase.instance.cacheCategories(values, replaceType: type);
|
||||||
return values
|
LocalDatabase.instance.ensureDefaultCategories(type: type);
|
||||||
.map((item) => CategoryItem.fromJson(item as Map<String, dynamic>))
|
return LocalDatabase.instance
|
||||||
|
.categories(type)
|
||||||
|
.map(CategoryItem.fromJson)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +428,7 @@ class TxApi {
|
|||||||
String? recognitionOccurrenceId,
|
String? recognitionOccurrenceId,
|
||||||
String? evidenceFingerprint,
|
String? evidenceFingerprint,
|
||||||
String? recognitionConfidence,
|
String? recognitionConfidence,
|
||||||
|
bool localFirst = false,
|
||||||
}) async {
|
}) async {
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
'ledgerId': _ledgerId,
|
'ledgerId': _ledgerId,
|
||||||
@@ -456,7 +456,10 @@ class TxApi {
|
|||||||
'recognitionConfidence': recognitionConfidence,
|
'recognitionConfidence': recognitionConfidence,
|
||||||
};
|
};
|
||||||
final session = SessionStore.instance;
|
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);
|
final local = LocalDatabase.instance.createTransaction(payload);
|
||||||
if (_queueOfflineChanges) {
|
if (_queueOfflineChanges) {
|
||||||
LocalDatabase.instance.enqueueSync(
|
LocalDatabase.instance.enqueueSync(
|
||||||
@@ -465,6 +468,7 @@ class TxApi {
|
|||||||
'create',
|
'create',
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
|
_scheduleBackgroundSync();
|
||||||
}
|
}
|
||||||
return TxItem.fromJson(local);
|
return TxItem.fromJson(local);
|
||||||
}
|
}
|
||||||
@@ -1698,8 +1702,9 @@ class CategoryApi {
|
|||||||
String name,
|
String name,
|
||||||
String iconKey,
|
String iconKey,
|
||||||
String colorKey,
|
String colorKey,
|
||||||
String type,
|
String type, {
|
||||||
) async {
|
bool localFirst = false,
|
||||||
|
}) async {
|
||||||
final payload = {
|
final payload = {
|
||||||
'name': name,
|
'name': name,
|
||||||
'iconKey': iconKey,
|
'iconKey': iconKey,
|
||||||
@@ -1707,7 +1712,8 @@ class CategoryApi {
|
|||||||
'type': type,
|
'type': type,
|
||||||
};
|
};
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly ||
|
if (localFirst ||
|
||||||
|
session.shouldUseLocalOnly ||
|
||||||
ApiClient.availability.value == BackendAvailability.offline) {
|
ApiClient.availability.value == BackendAvailability.offline) {
|
||||||
final local = LocalDatabase.instance.createCategory(
|
final local = LocalDatabase.instance.createCategory(
|
||||||
name,
|
name,
|
||||||
@@ -1722,6 +1728,7 @@ class CategoryApi {
|
|||||||
'create',
|
'create',
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
|
_scheduleBackgroundSync();
|
||||||
}
|
}
|
||||||
return CategoryItem.fromJson(local);
|
return CategoryItem.fromJson(local);
|
||||||
}
|
}
|
||||||
@@ -1754,6 +1761,7 @@ class CategoryApi {
|
|||||||
required String iconKey,
|
required String iconKey,
|
||||||
required String colorKey,
|
required String colorKey,
|
||||||
required int sortOrder,
|
required int sortOrder,
|
||||||
|
bool localFirst = false,
|
||||||
}) async {
|
}) async {
|
||||||
final payload = {
|
final payload = {
|
||||||
'name': name,
|
'name': name,
|
||||||
@@ -1762,7 +1770,8 @@ class CategoryApi {
|
|||||||
'sortOrder': sortOrder,
|
'sortOrder': sortOrder,
|
||||||
};
|
};
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly ||
|
if (localFirst ||
|
||||||
|
session.shouldUseLocalOnly ||
|
||||||
ApiClient.availability.value == BackendAvailability.offline ||
|
ApiClient.availability.value == BackendAvailability.offline ||
|
||||||
id < 0) {
|
id < 0) {
|
||||||
final local = LocalDatabase.instance.updateCategory(
|
final local = LocalDatabase.instance.updateCategory(
|
||||||
@@ -1774,6 +1783,7 @@ class CategoryApi {
|
|||||||
);
|
);
|
||||||
if (session.isAccount && session.cloudSyncEnabled) {
|
if (session.isAccount && session.cloudSyncEnabled) {
|
||||||
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
|
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
|
||||||
|
_scheduleBackgroundSync();
|
||||||
}
|
}
|
||||||
return CategoryItem.fromJson(local);
|
return CategoryItem.fromJson(local);
|
||||||
}
|
}
|
||||||
@@ -1796,10 +1806,15 @@ class CategoryApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
LocalDatabase.instance.reorderCategories(type, categoryIds);
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly ||
|
if (localFirst ||
|
||||||
|
session.shouldUseLocalOnly ||
|
||||||
ApiClient.availability.value == BackendAvailability.offline ||
|
ApiClient.availability.value == BackendAvailability.offline ||
|
||||||
categoryIds.any((id) => id < 0)) {
|
categoryIds.any((id) => id < 0)) {
|
||||||
if (session.isAccount && session.cloudSyncEnabled) {
|
if (session.isAccount && session.cloudSyncEnabled) {
|
||||||
@@ -1807,6 +1822,7 @@ class CategoryApi {
|
|||||||
'type': type,
|
'type': type,
|
||||||
'categoryIds': categoryIds,
|
'categoryIds': categoryIds,
|
||||||
});
|
});
|
||||||
|
_scheduleBackgroundSync();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1824,9 +1840,10 @@ class CategoryApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> delete(int id) async {
|
static Future<void> delete(int id, {bool localFirst = false}) async {
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly ||
|
if (localFirst ||
|
||||||
|
session.shouldUseLocalOnly ||
|
||||||
ApiClient.availability.value == BackendAvailability.offline ||
|
ApiClient.availability.value == BackendAvailability.offline ||
|
||||||
id < 0) {
|
id < 0) {
|
||||||
LocalDatabase.instance.deleteCategory(id);
|
LocalDatabase.instance.deleteCategory(id);
|
||||||
@@ -1834,6 +1851,7 @@ class CategoryApi {
|
|||||||
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
|
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
|
||||||
'id': id,
|
'id': id,
|
||||||
});
|
});
|
||||||
|
_scheduleBackgroundSync();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1847,3 +1865,8 @@ class CategoryApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scheduleBackgroundSync() {
|
||||||
|
SyncService.instance.refreshLocalStatus();
|
||||||
|
unawaited(SyncService.instance.run());
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class GuestMergeService {
|
|||||||
(response.data as List).map((item) => item as Map<String, dynamic>),
|
(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 []) {
|
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
|
||||||
final category = value as Map<String, dynamic>;
|
final category = value as Map<String, dynamic>;
|
||||||
@@ -92,6 +94,7 @@ class GuestMergeService {
|
|||||||
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
|
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
|
||||||
final fallback = available.firstWhere(
|
final fallback = available.firstWhere(
|
||||||
(item) => item['type'] == categoryType && item['name'] == '其他',
|
(item) => item['type'] == categoryType && item['name'] == '其他',
|
||||||
|
orElse: () => throw StateError('$categoryType 分类缺少“其他”,无法导入本机账单'),
|
||||||
);
|
);
|
||||||
return (fallback['id'] as num).toInt();
|
return (fallback['id'] as num).toInt();
|
||||||
}
|
}
|
||||||
@@ -151,6 +154,40 @@ class GuestMergeService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
static int? _findMappedDefault(
|
||||||
List<Map<String, dynamic>> available,
|
List<Map<String, dynamic>> available,
|
||||||
Map<String, dynamic> snapshot,
|
Map<String, dynamic> snapshot,
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ class LocalDatabase {
|
|||||||
static const _secureStorage = FlutterSecureStorage();
|
static const _secureStorage = FlutterSecureStorage();
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static LocalDatabase inMemoryForTesting() {
|
static LocalDatabase inMemoryForTesting({bool seedDefaults = true}) {
|
||||||
final database = LocalDatabase._();
|
final database = LocalDatabase._();
|
||||||
database._database = sqlite3.openInMemory();
|
database._database = sqlite3.openInMemory();
|
||||||
database._namespace = 'test';
|
database._namespace = 'test';
|
||||||
database._migrate();
|
database._migrate();
|
||||||
database._seedDefaults();
|
if (seedDefaults) database._seedDefaults();
|
||||||
return database;
|
return database;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,11 @@ class LocalDatabase {
|
|||||||
_database = database;
|
_database = database;
|
||||||
_namespace = namespace;
|
_namespace = namespace;
|
||||||
_migrate();
|
_migrate();
|
||||||
if (namespace == 'guest') _seedDefaults();
|
if (namespace == 'guest') {
|
||||||
|
_seedDefaults();
|
||||||
|
} else {
|
||||||
|
ensureDefaultCategories();
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
database.close();
|
database.close();
|
||||||
rethrow;
|
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() {
|
Map<String, dynamic> guestSnapshot() {
|
||||||
final customCategories = _db
|
final customCategories = _db
|
||||||
.select('''
|
.select('''
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class RecognitionDiagnosticDisplay {
|
|||||||
'matched' || 'auto_ready' => '已识别',
|
'matched' || 'auto_ready' => '已识别',
|
||||||
'confirm' => '待确认',
|
'confirm' => '待确认',
|
||||||
'started' => '处理中',
|
'started' => '处理中',
|
||||||
|
'waiting' => '等待结果页',
|
||||||
'failed' => '失败',
|
'failed' => '失败',
|
||||||
_ => '未触发入账',
|
_ => '未触发入账',
|
||||||
};
|
};
|
||||||
@@ -56,6 +57,7 @@ class RecognitionDiagnosticDisplay {
|
|||||||
|
|
||||||
static String _summaryLabel(String result, String reason) {
|
static String _summaryLabel(String result, String reason) {
|
||||||
if (reason == 'duplicate_result_surface') return '已合并';
|
if (reason == 'duplicate_result_surface') return '已合并';
|
||||||
|
if (result == 'waiting') return '等待结果页';
|
||||||
if (_captureFailureReasons.contains(reason)) return '截图失败';
|
if (_captureFailureReasons.contains(reason)) return '截图失败';
|
||||||
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
|
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
|
||||||
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
|
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
|
||||||
@@ -65,6 +67,7 @@ class RecognitionDiagnosticDisplay {
|
|||||||
'matched' || 'auto_ready' => '已识别',
|
'matched' || 'auto_ready' => '已识别',
|
||||||
'confirm' => '待确认',
|
'confirm' => '待确认',
|
||||||
'started' => '处理中',
|
'started' => '处理中',
|
||||||
|
'waiting' => '等待结果页',
|
||||||
'failed' => '失败',
|
'failed' => '失败',
|
||||||
_ => '没事件',
|
_ => '没事件',
|
||||||
};
|
};
|
||||||
@@ -75,12 +78,16 @@ class RecognitionDiagnosticDisplay {
|
|||||||
'history_page' => '当前是账单或交易历史页',
|
'history_page' => '当前是账单或交易历史页',
|
||||||
'blocked_status' => '当前状态为失败、处理中或已取消',
|
'blocked_status' => '当前状态为失败、处理中或已取消',
|
||||||
'no_text' => '截图中没有识别到文字',
|
'no_text' => '截图中没有识别到文字',
|
||||||
'no_success_status' => '没有找到明确或弱完成状态,可开启诊断预览查看脱敏结果',
|
'no_success_status' => '暂未找到完成状态,正在按计划复核结果页',
|
||||||
'payment_input_page' => '当前仍是付款输入或确认页面,已拒绝入账',
|
'payment_input_page' => '当前仍是付款输入或确认页面,等待结果页,不会提前入账',
|
||||||
|
'result_page_timeout' => '90 秒内未等到明确结果页,本次流程已停止追踪',
|
||||||
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
|
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
|
||||||
'missing_amount' => '成功状态已识别,但没有找到金额',
|
'missing_amount' => '成功状态已识别,但没有找到金额',
|
||||||
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
|
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
|
||||||
'expected_amount_fallback' => '结果页金额缺失,已使用付款前确认的唯一金额',
|
'expected_amount_fallback' => '结果页未读到金额,已使用本次付款流程确认的金额',
|
||||||
|
'weak_expected_fallback' => '结果页未读到金额,付款前金额来源不可靠,请确认后入账',
|
||||||
|
'result_amount_conflict' => '结果页金额与本次付款金额不一致,请确认后入账',
|
||||||
|
'amount_source_untrusted' => '金额无法与当前付款流程可靠关联,请确认后入账',
|
||||||
'red_packet_not_settled' => '红包尚未明确到账或退回,不会自动入账',
|
'red_packet_not_settled' => '红包尚未明确到账或退回,不会自动入账',
|
||||||
'duplicate_result_surface' => '同一结果页已处理,本次刷新已忽略',
|
'duplicate_result_surface' => '同一结果页已处理,本次刷新已忽略',
|
||||||
'weak_status_confirm' => '只识别到弱完成状态,组合证据不足,需确认后入账',
|
'weak_status_confirm' => '只识别到弱完成状态,组合证据不足,需确认后入账',
|
||||||
@@ -123,7 +130,7 @@ class RecognitionDiagnosticDisplay {
|
|||||||
|
|
||||||
static String? _amountSourceLabel(String? value) {
|
static String? _amountSourceLabel(String? value) {
|
||||||
return switch (value) {
|
return switch (value) {
|
||||||
'expected' => '使用付款前金额',
|
'expected' => '使用本次付款金额',
|
||||||
'result' => '使用结果页金额',
|
'result' => '使用结果页金额',
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
@@ -164,13 +171,15 @@ class RecognitionDiagnosticDisplay {
|
|||||||
static const _ruleRejectedReasons = {
|
static const _ruleRejectedReasons = {
|
||||||
'history_page',
|
'history_page',
|
||||||
'blocked_status',
|
'blocked_status',
|
||||||
'no_success_status',
|
'result_page_timeout',
|
||||||
'payment_input_page',
|
|
||||||
'direction_unknown',
|
'direction_unknown',
|
||||||
'missing_amount',
|
'missing_amount',
|
||||||
'expected_amount_missing',
|
'expected_amount_missing',
|
||||||
'red_packet_not_settled',
|
'red_packet_not_settled',
|
||||||
'weak_status_confirm',
|
'weak_status_confirm',
|
||||||
'ambiguous_or_unarmed',
|
'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 {
|
class RecognitionStatus {
|
||||||
final bool accessibilityAuthorized;
|
final bool accessibilityAuthorized;
|
||||||
final bool accessibilityConnected;
|
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 notificationAuthorized;
|
||||||
final bool notificationConnected;
|
final bool notificationConnected;
|
||||||
final bool postNotificationsGranted;
|
final bool postNotificationsGranted;
|
||||||
@@ -81,6 +119,19 @@ class RecognitionStatus {
|
|||||||
const RecognitionStatus({
|
const RecognitionStatus({
|
||||||
required this.accessibilityAuthorized,
|
required this.accessibilityAuthorized,
|
||||||
required this.accessibilityConnected,
|
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.notificationAuthorized,
|
||||||
required this.notificationConnected,
|
required this.notificationConnected,
|
||||||
required this.postNotificationsGranted,
|
required this.postNotificationsGranted,
|
||||||
@@ -101,10 +152,39 @@ class RecognitionStatus {
|
|||||||
final settings = rawSettings == null || rawSettings.isEmpty
|
final settings = rawSettings == null || rawSettings.isEmpty
|
||||||
? const <String, dynamic>{}
|
? const <String, dynamic>{}
|
||||||
: jsonDecode(rawSettings) as Map<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(
|
return RecognitionStatus(
|
||||||
accessibilityAuthorized:
|
accessibilityAuthorized: accessibilityAuthorized,
|
||||||
value['accessibilityAuthorized'] as bool? ?? false,
|
accessibilityConnected: accessibilityConnected,
|
||||||
accessibilityConnected: value['accessibilityConnected'] as bool? ?? false,
|
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,
|
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
|
||||||
notificationConnected: value['notificationConnected'] as bool? ?? false,
|
notificationConnected: value['notificationConnected'] as bool? ?? false,
|
||||||
postNotificationsGranted:
|
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 {
|
class RecognitionCandidate {
|
||||||
@@ -254,6 +349,7 @@ class SpeechEvent {
|
|||||||
/// Android native capabilities for screenshots, AI progress and speech.
|
/// Android native capabilities for screenshots, AI progress and speech.
|
||||||
class ScreenshotChannel {
|
class ScreenshotChannel {
|
||||||
static const _channel = MethodChannel('com.miaoji/screenshot');
|
static const _channel = MethodChannel('com.miaoji/screenshot');
|
||||||
|
static Future<RecognitionStatus>? _activeAccessibilityConnectionWait;
|
||||||
static void Function(String path)? _screenshotReady;
|
static void Function(String path)? _screenshotReady;
|
||||||
static void Function(String error)? _screenshotError;
|
static void Function(String error)? _screenshotError;
|
||||||
static void Function(SpeechEvent event)? _speechEvent;
|
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 {
|
static Future<bool> clearRecognitionDiagnostic() async {
|
||||||
try {
|
try {
|
||||||
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
|
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
|
||||||
|
|||||||
@@ -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_controls.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||||
|
|
||||||
enum AiAccessState { guest, reauthenticate, cloudDisabled }
|
enum AiAccessState { guest, reauthenticate, aiDisabled, cloudDisabled }
|
||||||
|
|
||||||
AiAccessState? currentAiAccessState() {
|
AiAccessState? currentAiAccessState() {
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.isGuest) return AiAccessState.guest;
|
if (session.isGuest) return AiAccessState.guest;
|
||||||
if (session.needsReauth) return AiAccessState.reauthenticate;
|
if (session.needsReauth) return AiAccessState.reauthenticate;
|
||||||
|
if (!session.aiEnabled) return AiAccessState.aiDisabled;
|
||||||
if (!session.cloudSyncEnabled) return AiAccessState.cloudDisabled;
|
if (!session.cloudSyncEnabled) return AiAccessState.cloudDisabled;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -32,23 +33,27 @@ class _AiAccessGateState extends State<AiAccessGate> {
|
|||||||
String get _title => switch (widget.state) {
|
String get _title => switch (widget.state) {
|
||||||
AiAccessState.guest => '登录后使用 AI 助手',
|
AiAccessState.guest => '登录后使用 AI 助手',
|
||||||
AiAccessState.reauthenticate => '登录状态已过期',
|
AiAccessState.reauthenticate => '登录状态已过期',
|
||||||
|
AiAccessState.aiDisabled => 'AI 功能已关闭',
|
||||||
AiAccessState.cloudDisabled => 'AI 功能需要云连接',
|
AiAccessState.cloudDisabled => 'AI 功能需要云连接',
|
||||||
};
|
};
|
||||||
|
|
||||||
String get _message => switch (widget.state) {
|
String get _message => switch (widget.state) {
|
||||||
AiAccessState.guest => '游客账单会继续安全保存在本机。登录后即可使用 AI 聊天、语音解析和图片识别。',
|
AiAccessState.guest => '游客账单会继续安全保存在本机。登录后即可使用 AI 聊天、语音解析和图片识别。',
|
||||||
AiAccessState.reauthenticate => '本地记账不受影响。重新登录后可以继续使用 AI 和云同步。',
|
AiAccessState.reauthenticate => '本地记账不受影响。重新登录后可以继续使用 AI 和云同步。',
|
||||||
|
AiAccessState.aiDisabled => '当前账号暂未开通 AI,手动记账和统计不受影响。',
|
||||||
AiAccessState.cloudDisabled => '当前账号仅使用本地数据。开启云同步后才能发送 AI 消息。',
|
AiAccessState.cloudDisabled => '当前账号仅使用本地数据。开启云同步后才能发送 AI 消息。',
|
||||||
};
|
};
|
||||||
|
|
||||||
String get _actionLabel => switch (widget.state) {
|
String? get _actionLabel => switch (widget.state) {
|
||||||
AiAccessState.guest => '登录后使用',
|
AiAccessState.guest => '登录后使用',
|
||||||
AiAccessState.reauthenticate => '重新登录',
|
AiAccessState.reauthenticate => '重新登录',
|
||||||
|
AiAccessState.aiDisabled => null,
|
||||||
AiAccessState.cloudDisabled => '开启云同步',
|
AiAccessState.cloudDisabled => '开启云同步',
|
||||||
};
|
};
|
||||||
|
|
||||||
Future<void> _act() async {
|
Future<void> _act() async {
|
||||||
if (_busy) return;
|
if (_busy) return;
|
||||||
|
if (widget.state == AiAccessState.aiDisabled) return;
|
||||||
if (widget.onAction != null) {
|
if (widget.onAction != null) {
|
||||||
await widget.onAction!();
|
await widget.onAction!();
|
||||||
return;
|
return;
|
||||||
@@ -115,15 +120,17 @@ class _AiAccessGateState extends State<AiAccessGate> {
|
|||||||
height: 1.6,
|
height: 1.6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 22),
|
if (_actionLabel case final label?) ...[
|
||||||
SizedBox(
|
SizedBox(height: 22),
|
||||||
width: double.infinity,
|
SizedBox(
|
||||||
child: JzActionButton(
|
width: double.infinity,
|
||||||
label: _actionLabel,
|
child: JzActionButton(
|
||||||
loading: _busy,
|
label: label,
|
||||||
onPressed: _busy ? null : _act,
|
loading: _busy,
|
||||||
|
onPressed: _busy ? null : _act,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
if (widget.state == AiAccessState.guest) ...[
|
if (widget.state == AiAccessState.guest) ...[
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -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/business_api.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.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/current_ledger_store.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -176,6 +177,55 @@ void main() {
|
|||||||
expect(report.balance, 2400);
|
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 个且键值不重复', () {
|
test('分类图标目录至少 32 个且键值不重复', () {
|
||||||
final keys = AppIcons.categoryCatalog.map((item) => item.key).toList();
|
final keys = AppIcons.categoryCatalog.map((item) => item.key).toList();
|
||||||
expect(keys.length, greaterThanOrEqualTo(32));
|
expect(keys.length, greaterThanOrEqualTo(32));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/auth_api.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';
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -79,6 +80,31 @@ void main() {
|
|||||||
expect(companion.toJson(), 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('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () {
|
test('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () {
|
||||||
final contracts = <String, List<String>>{
|
final contracts = <String, List<String>>{
|
||||||
'lib/features/home/pages/home_page.dart': [
|
'lib/features/home/pages/home_page.dart': [
|
||||||
@@ -92,6 +118,7 @@ void main() {
|
|||||||
"categoriesLocal('expense')",
|
"categoriesLocal('expense')",
|
||||||
"categoriesRemote('expense')",
|
"categoriesRemote('expense')",
|
||||||
"categoriesRemote('income')",
|
"categoriesRemote('income')",
|
||||||
|
'localFirst: true',
|
||||||
],
|
],
|
||||||
'lib/features/stats/stats_page.dart': [
|
'lib/features/stats/stats_page.dart': [
|
||||||
'periodStatsLocal',
|
'periodStatsLocal',
|
||||||
@@ -104,6 +131,7 @@ void main() {
|
|||||||
'lib/features/settings/category_manage_page.dart': [
|
'lib/features/settings/category_manage_page.dart': [
|
||||||
'categoriesLocal',
|
'categoriesLocal',
|
||||||
'categoriesRemote',
|
'categoriesRemote',
|
||||||
|
'localFirst: true',
|
||||||
],
|
],
|
||||||
'lib/features/settings/recycle_bin_page.dart': [
|
'lib/features/settings/recycle_bin_page.dart': [
|
||||||
'recycleBinLocal',
|
'recycleBinLocal',
|
||||||
|
|||||||
@@ -153,9 +153,21 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
RecognitionDiagnosticDisplay.from(
|
RecognitionDiagnosticDisplay.from(
|
||||||
diagnostic('rejected', 'payment_input_page'),
|
diagnostic('waiting', 'payment_input_page'),
|
||||||
).summaryLabel,
|
).summaryLabel,
|
||||||
'规则拒绝',
|
'等待结果页',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
RecognitionDiagnosticDisplay.from(
|
||||||
|
diagnostic('waiting', 'payment_input_page'),
|
||||||
|
).reasonLabel,
|
||||||
|
'当前仍是付款输入或确认页面,等待结果页,不会提前入账',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
RecognitionDiagnosticDisplay.from(
|
||||||
|
diagnostic('rejected', 'result_page_timeout'),
|
||||||
|
).reasonLabel,
|
||||||
|
'90 秒内未等到明确结果页,本次流程已停止追踪',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
RecognitionDiagnosticDisplay.from(
|
RecognitionDiagnosticDisplay.from(
|
||||||
@@ -163,5 +175,17 @@ void main() {
|
|||||||
).summaryLabel,
|
).summaryLabel,
|
||||||
'已合并',
|
'已合并',
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
RecognitionDiagnosticDisplay.from(
|
||||||
|
diagnostic('confirm', 'expected_amount_fallback'),
|
||||||
|
).reasonLabel,
|
||||||
|
'结果页未读到金额,已使用本次付款流程确认的金额',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
RecognitionDiagnosticDisplay.from(
|
||||||
|
diagnostic('confirm', 'result_amount_conflict'),
|
||||||
|
).reasonLabel,
|
||||||
|
'结果页金额与本次付款金额不一致,请确认后入账',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ void main() {
|
|||||||
final companion = File(
|
final companion = File(
|
||||||
'lib/features/settings/companion_page.dart',
|
'lib/features/settings/companion_page.dart',
|
||||||
).readAsStringSync();
|
).readAsStringSync();
|
||||||
|
final onboarding = File(
|
||||||
|
'lib/features/onboarding/pages/onboarding_page.dart',
|
||||||
|
).readAsStringSync();
|
||||||
final me = File('lib/features/settings/me_page.dart').readAsStringSync();
|
final me = File('lib/features/settings/me_page.dart').readAsStringSync();
|
||||||
final report = File(
|
final report = File(
|
||||||
'lib/features/stats/report_page.dart',
|
'lib/features/stats/report_page.dart',
|
||||||
@@ -80,10 +83,27 @@ void main() {
|
|||||||
companion,
|
companion,
|
||||||
isNot(contains('context.jz.aiBackground : Colors.white')),
|
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(me, isNot(contains('context.jz.primaryBackground : Colors.white')));
|
||||||
expect(report, isNot(contains('selected ? 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('聊天附件只保留拍照和相册导入', () {
|
test('聊天附件只保留拍照和相册导入', () {
|
||||||
final source = File('lib/features/chat/chat_page.dart').readAsStringSync();
|
final source = File('lib/features/chat/chat_page.dart').readAsStringSync();
|
||||||
|
|
||||||
@@ -94,6 +114,49 @@ void main() {
|
|||||||
expect(source, isNot(contains('ScreenshotChannel.capture')));
|
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('表情包具备离线缓存、内置兜底和展开刷新', () {
|
test('表情包具备离线缓存、内置兜底和展开刷新', () {
|
||||||
final api = File('lib/shared/api/business_api.dart').readAsStringSync();
|
final api = File('lib/shared/api/business_api.dart').readAsStringSync();
|
||||||
final chat = File('lib/features/chat/chat_page.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('setRecognitionToggle(key, false)'));
|
||||||
expect(source, contains('_BackgroundKeepAliveCard'));
|
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'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user