From 7438fb6725a5cb9dd76461b008cadfa2843a55ea Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 22:32:07 +0800 Subject: [PATCH 01/70] =?UTF-8?q?=E9=87=8D=E5=86=99=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.prod.example | 49 ++- deploy/README.md | 80 +++-- deploy/docker-compose.prod.yml | 27 -- deploy/mysql/my.cnf | 10 + scripts/archive-binlog.sh | 175 +++++++++++ scripts/backup-common.sh | 193 ++++++++++++ scripts/backup-online.sh | 206 +++++++++++++ scripts/backup-prod.sh | 533 --------------------------------- scripts/deploy-prod.sh | 86 +++++- scripts/restore-online.sh | 248 +++++++++++++++ 10 files changed, 999 insertions(+), 608 deletions(-) create mode 100755 scripts/archive-binlog.sh create mode 100755 scripts/backup-common.sh create mode 100755 scripts/backup-online.sh delete mode 100755 scripts/backup-prod.sh create mode 100755 scripts/restore-online.sh diff --git a/backend/.env.prod.example b/backend/.env.prod.example index aace48e..b5db876 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -48,26 +48,23 @@ LOG_ENABLE_CONSOLE=false LOG_ENABLE_FILE=true LOG_RETAIN_DAYS=14 -# MinIO 容器初始化变量。迁移完成前仍保留,用于回退与校验。 -MINIO_ROOT_USER=change-minio-user -MINIO_ROOT_PASSWORD=change-minio-password -# 当前主对象存储。使用内置 MinIO 时,STORAGE_* 密钥必须和 MINIO_ROOT_* 保持一致。 -STORAGE_ENDPOINT=http://minio:9000 -STORAGE_BUCKET=hfb-sys -STORAGE_ACCESS_KEY_ID=change-minio-user -STORAGE_SECRET_ACCESS_KEY=change-minio-password -STORAGE_REGION= -STORAGE_BUCKET_LOOKUP=auto - -# OSS 迁移镜像端。迁移期间设置后,新上传文件会同时写入 MinIO 与 OSS。 +# 主对象存储:图片等数据已迁移到阿里云 OSS,MinIO 已从生产编排移除。 # 杭州 ECS 应使用内网 Endpoint:https://oss-cn-hangzhou-internal.aliyuncs.com # OSS 的 S3 兼容访问使用 cn-hangzhou 区域和 DNS Bucket 寻址。 +STORAGE_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com +STORAGE_BUCKET=hfb-sys-assets +STORAGE_ACCESS_KEY_ID=change-oss-access-key-id +STORAGE_SECRET_ACCESS_KEY=change-oss-access-key-secret +STORAGE_REGION=cn-hangzhou +STORAGE_BUCKET_LOOKUP=dns + +# 历史镜像写入配置,迁移完成后保持为空。 STORAGE_MIRROR_ENDPOINT= -STORAGE_MIRROR_BUCKET=hfb-sys-assets +STORAGE_MIRROR_BUCKET= STORAGE_MIRROR_ACCESS_KEY_ID= STORAGE_MIRROR_SECRET_ACCESS_KEY= -STORAGE_MIRROR_REGION=cn-hangzhou -STORAGE_MIRROR_BUCKET_LOOKUP=dns +STORAGE_MIRROR_REGION= +STORAGE_MIRROR_BUCKET_LOOKUP= # 生产环境建议接入真实短信服务;未配置时不要使用 mock 对外运营。 SMS_PROVIDER=aliyun @@ -102,3 +99,25 @@ FIELD_ENCRYPTION_LEGACY_KEY= EXTERNAL_UPLOAD_SECRET= # 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。 EXTERNAL_UPLOAD_ALLOWED_IPS= + +# 在线备份(scripts/backup-online.sh / scripts/archive-binlog.sh)。所有项为生产必填。 +# 本地备份根目录,必须是独立数据盘上的 /data 子目录;包含加密全量备份与 binlog 状态。 +BACKUP_DIR=/data/backups +# 隔离恢复演练的数据目录,必须与 BACKUP_DIR 不同;prepare 后的 MySQL 原始数据只放在这里。 +BACKUP_RESTORE_DIR=/data/restore +# 备份专用 OSS Bucket(与业务 Bucket 分离),ossutil 使用。 +# 生成方式:ossutil config 交互配置,或环境变量 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET。 +BACKUP_OSS_URI=oss://hfb-backup +# 客户端加密密码短语(独立保管,与业务密钥一同纳入 KMS/密码管理)。 +# 生成方式:openssl rand -hex 32 +BACKUP_PASSPHRASE=change-to-random-backup-passphrase +# OSS 服务端 KMS 加密 Key ID,备份脚本强制使用,不可留空。 +BACKUP_KMS_KEY_ID=change-kms-key-id +# XtraBackup 专用 MySQL 账号,部署脚本会幂等创建并授予最小备份权限。 +BACKUP_MYSQL_USER=hfb_backup +# 生成方式:openssl rand -hex 24。不要和 MySQL 应用账号或 root 密码复用。 +BACKUP_MYSQL_PASSWORD=change-backup-mysql-password +# 本地保留策略(OSS 侧用 Bucket 生命周期规则管理,见脚本头部注释)。 +BACKUP_KEEP_DAILY=14 +BACKUP_KEEP_WEEKLY=8 +BACKUP_KEEP_MONTHLY=12 diff --git a/deploy/README.md b/deploy/README.md index c97d804..97221c2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -8,7 +8,7 @@ https://选号网域名 # CADDY_SHOW_DOMAIN(可选),hfb_show 静态 https://monitor.主站域名 # Beszel 监控 ``` -生产部署使用 Caddy 作为公网入口,只暴露宿主机 `80`、`443` 端口。Caddy 会自动申请和续签 HTTPS 证书,并直接托管前端静态资源(主站 `dist` 与可选的选号网 `show-dist` 在构建 Caddy 镜像时打入);后端、MySQL、Redis、MinIO 均通过 Docker 内网通信。选号网与主站**共用同一 backend**,浏览器访问选号网域名下的 `/api/*` 由 Caddy 反代到本机 backend,**不会直连数据库**。 +生产部署使用 Caddy 作为公网入口,只暴露宿主机 `80`、`443` 端口。Caddy 会自动申请和续签 HTTPS 证书,并直接托管前端静态资源(主站 `dist` 与可选的选号网 `show-dist` 在构建 Caddy 镜像时打入);后端、MySQL、Redis 均通过 Docker 内网通信,图片对象存储使用阿里云 OSS。选号网与主站**共用同一 backend**,浏览器访问选号网域名下的 `/api/*` 由 Caddy 反代到本机 backend,**不会直连数据库**。 部署前请确认: @@ -32,12 +32,16 @@ MYSQL_ROOT_PASSWORD MYSQL_PASSWORD MYSQL_DSN JWT_SECRET -MINIO_ROOT_USER -MINIO_ROOT_PASSWORD STORAGE_ACCESS_KEY_ID STORAGE_SECRET_ACCESS_KEY BACKEND_UID BACKEND_GID +BACKUP_DIR +BACKUP_RESTORE_DIR +BACKUP_OSS_URI +BACKUP_PASSPHRASE +BACKUP_KMS_KEY_ID +BACKUP_MYSQL_PASSWORD ``` `BACKEND_UID`、`BACKEND_GID` 填写服务器业务用户的数值 ID,例如: @@ -49,29 +53,68 @@ id -g yml 后端容器会使用该 UID/GID 运行。部署脚本由 root 执行时会自动把已有日志修正为该属主;非 root 执行且发现旧的 root 日志时,会给出需要执行的 `chown` 命令后停止。 -### 迁移对象存储到阿里云 OSS +### 对象存储:阿里云 OSS -创建私有 Bucket 后,先保持 `STORAGE_*` 指向当前 MinIO,在 `backend/.env` 配置 OSS 镜像端: +图片等业务文件存储在私有 OSS Bucket,`backend/.env` 的 `STORAGE_*` 直接指向 OSS: ```text -STORAGE_MIRROR_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com -STORAGE_MIRROR_BUCKET=hfb-sys-assets -STORAGE_MIRROR_ACCESS_KEY_ID=change-oss-access-key-id -STORAGE_MIRROR_SECRET_ACCESS_KEY=change-oss-access-key-secret -STORAGE_MIRROR_REGION=cn-hangzhou -STORAGE_MIRROR_BUCKET_LOOKUP=dns +STORAGE_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com +STORAGE_BUCKET=hfb-sys-assets +STORAGE_ACCESS_KEY_ID=change-oss-access-key-id +STORAGE_SECRET_ACCESS_KEY=change-oss-access-key-secret +STORAGE_REGION=cn-hangzhou +STORAGE_BUCKET_LOOKUP=dns ``` -RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要使用阿里云主账号 AccessKey。镜像端启用后,新上传文件会同步写入 MinIO 和 OSS;任一镜像写入失败时上传会失败,避免数据库引用未同步文件。 +RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要使用阿里云主账号 AccessKey。杭州 ECS 必须使用内网 Endpoint,避免跨公网访问。 -部署支持镜像配置的镜像后,执行以下命令同步历史对象;命令可重复执行,不会删除 MinIO 数据: +历史 MinIO 数据已通过 `scripts/migrate-minio-to-oss.sh` 迁移完成,生产编排不再包含 MinIO;旧 MinIO volume 观察期结束后可删除。 + +### 数据库在线备份与时间点恢复 + +生产 MySQL 开启了 `ROW` 格式 binlog 和崩溃安全刷盘。备份期间业务持续读写:每日用 XtraBackup 做物理全量备份,每分钟轮转并归档已关闭的 binlog。备份对象先做客户端加密,再强制以 OSS KMS 加密上传;远端仅在 payload、checksum 和 manifest 都上传成功后写入 `complete.env`。 + +部署脚本会创建或更新 `BACKUP_MYSQL_USER`,该用户只有 XtraBackup 所需的全局备份权限和业务库只读权限,不开放远程 root。 + +备份 Bucket 必须独立于业务 Bucket,备份 RAM 身份仅授予该 Bucket 的列举、读写对象及指定 KMS Key 的加解密权限。推荐 Bucket 开启版本控制和不可变保留策略。 + +部署机需安装 `ossutil`、`openssl` 和 `flock`,并使用与 cron 相同的业务用户完成 `ossutil` 凭证配置;部署脚本会在启动服务前检查这些依赖。 ```bash -./scripts/migrate-minio-to-oss.sh -./scripts/migrate-minio-to-oss.sh --verify +# 每日全量热备,本地留存并上传 OSS 备份桶 +./scripts/backup-online.sh full + +# binlog 分钟级归档,配合全量实现最近保留窗口内的时间点恢复 +./scripts/archive-binlog.sh archive + +# 校验本地状态对应的远端完整对象 / 清理过期本地备份 +./scripts/archive-binlog.sh verify +./scripts/backup-online.sh status +./scripts/backup-online.sh prune ``` -校验通过后,将 `STORAGE_*` 改为同一组 OSS 配置,再清空所有 `STORAGE_MIRROR_*` 配置并重新部署。杭州 ECS 必须使用 `oss-cn-hangzhou-internal.aliyuncs.com`,避免跨公网访问。MinIO 保留至少 30 天后再考虑下线。 +推荐 cron(以部署用户运行,并把日志写到受限目录): + +```cron +* * * * * cd /srv/hfb_sys && ./scripts/archive-binlog.sh archive >> /data/backups/archive-binlog.log 2>&1 +30 4 * * * cd /srv/hfb_sys && ./scripts/backup-online.sh full >> /data/backups/full-backup.log 2>&1 +15 5 * * * cd /srv/hfb_sys && ./scripts/backup-online.sh prune >> /data/backups/prune.log 2>&1 +10 5 * * 0 cd /srv/hfb_sys && ./scripts/archive-binlog.sh verify >> /data/backups/archive-verify.log 2>&1 +``` + +OSS 生命周期建议:`mysql/full/daily/` 30 天、`weekly/` 60 天、`monthly/` 365 天、`mysql/binlog/` 至少 45 天。时间点恢复窗口受最早全量备份和 binlog 保留期共同限制。 + +每周必须在隔离目录做一次恢复演练,绝不向生产容器回放: + +```bash +# 从 status 输出选取一个带 complete.env 的全量备份 URI。 +./scripts/restore-online.sh prepare oss://hfb-backup/mysql/full/daily///complete.env +./scripts/restore-online.sh fetch-all /data/restore// +./scripts/restore-online.sh start /data/restore// +./scripts/restore-online.sh replay /data/restore// hfb-restore- '2026-08-16 20:00:00' +``` + +恢复容器使用 `--network none`,只可通过 `docker exec` 检查。演练结束后手动删除名为 `hfb-restore-*` 的恢复容器和 `/data/restore` 对应目录;生产容器不在恢复工具的可选目标中。 ### 同机部署选号网(hfb_show,可选) @@ -108,12 +151,11 @@ HFB_SHOW_DIR=../hfb_show 脚本会自动完成: -- 检查 `backend/.env` 是否仍指向 `127.0.0.1`、`localhost` 或占位值。 -- 使用内置 MinIO 时,检查 `STORAGE_ACCESS_KEY_ID` / `STORAGE_SECRET_ACCESS_KEY` 是否和 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 一致。 +- 检查 `backend/.env` 是否仍指向 `127.0.0.1`、`localhost`、占位值或已下线的 MinIO。 - 构建并启动生产容器。 - 使用配置的非 root UID/GID 运行后端,并检查日志目录属主。 - 通过 Caddy 自动申请或续签 HTTPS 证书。 -- 等待 MySQL、Redis、MinIO 就绪。 +- 等待 MySQL、Redis 就绪。 - 按顺序执行尚未应用的数据库迁移。 - 重启后端并检查 `https://你的域名/api/health`。 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 7a224b8..4553324 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -75,30 +75,6 @@ services: timeout: 5s retries: 10 - minio: - image: minio/minio:RELEASE.2025-09-07T16-13-09Z - restart: unless-stopped - command: server /data --console-address ":9001" - env_file: - - ../backend/.env - environment: - TZ: Asia/Shanghai - volumes: - - minio_data:/data - deploy: - resources: - limits: - memory: 512M - cpus: '0.5' - reservations: - memory: 256M - cpus: '0.25' - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 10s - timeout: 5s - retries: 10 - backend: build: context: ../backend @@ -124,8 +100,6 @@ services: condition: service_healthy redis: condition: service_healthy - minio: - condition: service_healthy expose: - "8080" @@ -189,7 +163,6 @@ volumes: caddy_config: mysql_data: redis_data: - minio_data: beszel_data: beszel_socket: beszel_agent_data: diff --git a/deploy/mysql/my.cnf b/deploy/mysql/my.cnf index 4142561..81c389d 100644 --- a/deploy/mysql/my.cnf +++ b/deploy/mysql/my.cnf @@ -8,3 +8,13 @@ default-character-set = utf8mb4 character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci init-connect = 'SET NAMES utf8mb4' + +# 时间点恢复基础:binlog 由 scripts/archive-binlog.sh 每分钟归档到 OSS 备份桶。 +# 本地按 7 天自动过期,归档与本地保留策略见 scripts/backup-online.sh。 +server-id = 1 +log_bin = mysql-bin +binlog_format = ROW +binlog_row_image = FULL +binlog_expire_logs_seconds = 604800 +sync_binlog = 1 +innodb_flush_log_at_trx_commit = 1 diff --git a/scripts/archive-binlog.sh b/scripts/archive-binlog.sh new file mode 100755 index 0000000..2283ded --- /dev/null +++ b/scripts/archive-binlog.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 分钟级 binlog 归档。每次轮转后只归档已关闭文件;每个对象以 +# server_uuid、原始文件名和原始 sha256 命名,避免实例重建后的编号碰撞。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +MYSQL_DATA_DIR='/var/lib/mysql' +work_dir='' +cleanup() { + if [[ -n "${work_dir}" && -d "${work_dir}" ]]; then + rm -rf -- "${work_dir}" + fi +} +trap cleanup EXIT + +require_archive_config() { + backup_require_env BACKUP_DIR >/dev/null + backup_require_env BACKUP_OSS_URI >/dev/null + backup_require_env BACKUP_PASSPHRASE >/dev/null + backup_require_env BACKUP_KMS_KEY_ID >/dev/null +} + +closed_binlogs() { + local mysql_cid="$1" + docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "SHOW BINARY LOGS;"' \ + 2>/dev/null | awk 'NR > 1 { print previous } { previous = $1 } END { }' +} + +state_last_index() { + local state_dir="$1" + local value + [[ -f "${state_dir}/last-archived.env" ]] || return + value="$(awk -F= '$1 == "binlog_index" { print $2; exit }' "${state_dir}/last-archived.env")" + [[ "${value}" =~ ^[0-9]+$ ]] && printf '%s' "${value}" +} + +write_state() { + local state_dir="$1" filename="$2" index="$3" raw_sha="$4" cipher_sha="$5" remote_dir="$6" + { + printf 'filename=%s\n' "${filename}" + printf 'binlog_index=%s\n' "${index}" + printf 'raw_sha256=%s\n' "${raw_sha}" + printf 'cipher_sha256=%s\n' "${cipher_sha}" + printf 'remote_dir=%s\n' "${remote_dir}" + } > "${state_dir}/${filename}.${raw_sha}.state" + cp "${state_dir}/${filename}.${raw_sha}.state" "${state_dir}/last-archived.env" +} + +archive_one() { + local mysql_cid="$1" state_dir="$2" server_uuid="$3" oss_uri="$4" filename="$5" + local source_path source_sha raw_file raw_sha cipher_file cipher_sha cipher_size binlog_index object_id remote_dir + source_path="${MYSQL_DATA_DIR}/${filename}" + if ! docker exec "${mysql_cid}" test -f "${source_path}" >/dev/null 2>&1; then + backup_error "待归档 binlog 已从 MySQL 数据目录消失:${filename};PITR 链路已断裂" + exit 1 + fi + raw_file="${work_dir}/${filename}" + docker cp "${mysql_cid}:${source_path}" "${raw_file}" + raw_sha="$(backup_sha256 "${raw_file}")" + source_sha="$(docker exec "${mysql_cid}" sha256sum "${source_path}" | awk '{print $1}')" + [[ "${source_sha}" == "${raw_sha}" ]] || { + backup_error "binlog 拷贝校验失败:${filename}" + exit 1 + } + if compgen -G "${state_dir}/${filename}.${raw_sha}.state" >/dev/null; then + return + fi + binlog_index="$(backup_binlog_index "${filename}")" + object_id="${filename}.${raw_sha}" + remote_dir="${oss_uri}/mysql/binlog/${server_uuid}/${object_id}" + cipher_file="${work_dir}/${object_id}.enc" + backup_encrypt_file "${raw_file}" "${cipher_file}" + cipher_sha="$(backup_sha256 "${cipher_file}")" + cipher_size="$(backup_file_size "${cipher_file}")" + printf '%s %s\n' "${cipher_sha}" 'binlog.enc' > "${work_dir}/${object_id}.enc.sha256" + { + printf 'format_version=1\n' + printf 'server_uuid=%s\n' "${server_uuid}" + printf 'source_binlog=%s\n' "${filename}" + printf 'binlog_index=%s\n' "${binlog_index}" + printf 'raw_sha256=%s\n' "${raw_sha}" + printf 'payload=binlog.enc\n' + printf 'payload_sha256=%s\n' "${cipher_sha}" + printf 'payload_size_bytes=%s\n' "${cipher_size}" + printf 'created_at=%s\n' "$(date -Iseconds)" + } > "${work_dir}/${object_id}.complete.env" + + backup_oss_upload_kms "${cipher_file}" "${remote_dir}/binlog.enc" + backup_oss_upload_kms "${work_dir}/${object_id}.enc.sha256" "${remote_dir}/binlog.enc.sha256" + # complete.env 最后上传,恢复程序只使用有该文件的 binlog。 + backup_oss_upload_kms "${work_dir}/${object_id}.complete.env" "${remote_dir}/complete.env" + write_state "${state_dir}" "${filename}" "${binlog_index}" "${raw_sha}" "${cipher_sha}" "${remote_dir}" + rm -f -- "${raw_file}" "${cipher_file}" "${work_dir}/${object_id}.enc.sha256" "${work_dir}/${object_id}.complete.env" +} + +archive() { + backup_require_cmd docker + backup_require_cmd ossutil + backup_require_cmd openssl + require_archive_config + local backup_dir oss_uri mysql_cid server_uuid state_dir first_binlog last_index first_index expected_index filename index + backup_dir="$(backup_require_env BACKUP_DIR)" + backup_validate_dir "${backup_dir}" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + backup_validate_oss_uri "${oss_uri}" + backup_lock_or_exit "${backup_dir}/.binlog.lock" + mysql_cid="$(backup_mysql_container_id)" + server_uuid="$(backup_server_uuid)" + state_dir="${backup_dir}/binlog-state/${server_uuid}" + work_dir="${backup_dir}/.tmp-binlog-$$" + mkdir -p "${state_dir}" "${work_dir}" + + docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -e "FLUSH BINARY LOGS;"' >/dev/null + first_binlog="$(docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "SHOW BINARY LOGS;"' | head -1 | awk '{print $1}')" + [[ -n "${first_binlog}" ]] || { backup_error '无法读取 binlog 列表'; exit 1; } + last_index="$(state_last_index "${state_dir}" || true)" + first_index="$(backup_binlog_index "${first_binlog}")" + if [[ -n "${last_index}" && "${first_index}" -gt $((last_index + 1)) ]]; then + backup_error "发现未归档 binlog 缺口:已归档至序号 ${last_index},当前最早为 ${first_index}" + exit 1 + fi + + expected_index="${last_index}" + while IFS= read -r filename; do + [[ -n "${filename}" ]] || continue + index="$(backup_binlog_index "${filename}")" + if [[ -n "${expected_index}" && "${index}" -le "${expected_index}" ]]; then + continue + fi + if [[ -n "${expected_index}" && "${index}" -ne $((expected_index + 1)) ]]; then + backup_error "发现未归档 binlog 缺口:期望序号 $((expected_index + 1)),实际为 ${index}" + exit 1 + fi + archive_one "${mysql_cid}" "${state_dir}" "${server_uuid}" "${oss_uri}" "${filename}" + expected_index="${index}" + done < <(closed_binlogs "${mysql_cid}") + backup_log "binlog 归档完成,实例 ${server_uuid}" +} + +verify() { + backup_require_cmd ossutil + require_archive_config + local backup_dir server_uuid state_dir state_file remote_dir + backup_dir="$(backup_require_env BACKUP_DIR)" + backup_validate_dir "${backup_dir}" + backup_lock_or_exit "${backup_dir}/.binlog.lock" + server_uuid="$(backup_server_uuid)" + state_dir="${backup_dir}/binlog-state/${server_uuid}" + [[ -d "${state_dir}" ]] || { backup_error '尚未产生 binlog 归档状态'; exit 1; } + while IFS= read -r -d '' state_file; do + remote_dir="$(awk -F= '$1 == "remote_dir" { print substr($0, index($0, "=") + 1); exit }' "${state_file}")" + [[ -n "${remote_dir}" ]] || { backup_error "归档状态不完整:${state_file}"; exit 1; } + ossutil stat "${remote_dir}/binlog.enc" >/dev/null + ossutil stat "${remote_dir}/binlog.enc.sha256" >/dev/null + ossutil stat "${remote_dir}/complete.env" >/dev/null + done < <(find "${state_dir}" -maxdepth 1 -name '*.state' -print0) + backup_log '全部本地 binlog 归档状态均有远端完整对象' +} + +case "${1:-archive}" in + archive) archive ;; + verify) verify ;; + *) + printf '用法: %s {archive|verify}\n' "$0" >&2 + exit 1 + ;; +esac diff --git a/scripts/backup-common.sh b/scripts/backup-common.sh new file mode 100755 index 0000000..5f47e63 --- /dev/null +++ b/scripts/backup-common.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash + +# 在线备份脚本共用函数。仅供 scripts/backup-online.sh、archive-binlog.sh 和 +# restore-online.sh source,不要单独执行。 + +BACKUP_ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKUP_COMPOSE_FILE="${BACKUP_ROOT_DIR}/deploy/docker-compose.prod.yml" +BACKUP_ENV_FILE="${BACKUP_ROOT_DIR}/backend/.env" +BACKUP_XTRABACKUP_IMAGE="${XTRABACKUP_IMAGE:-percona/percona-xtrabackup:8.4}" + +backup_log() { + printf '[backup] %s\n' "$*" >&2 +} + +backup_error() { + printf '[backup] ERROR: %s\n' "$*" >&2 +} + +backup_require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + backup_error "缺少命令:$1" + exit 1 + fi +} + +backup_env_value() { + local key="$1" + awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "${BACKUP_ENV_FILE}" +} + +backup_require_env() { + local key="$1" + local value + value="$(backup_env_value "${key}")" + if [[ -z "${value}" ]]; then + backup_error "${BACKUP_ENV_FILE} 缺少或未填写 ${key}" + exit 1 + fi + printf '%s' "${value}" +} + +backup_compose() { + docker compose --env-file "${BACKUP_ENV_FILE}" -f "${BACKUP_COMPOSE_FILE}" "$@" +} + +backup_mysql_container_id() { + local cid + cid="$(backup_compose ps -q mysql 2>/dev/null || true)" + if [[ -z "${cid}" ]]; then + backup_error "MySQL 容器未运行" + exit 1 + fi + printf '%s' "${cid}" +} + +backup_mysql_network() { + local network + network="$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' "$1")" + if [[ -z "${network}" ]]; then + backup_error "无法读取 MySQL Docker 网络" + exit 1 + fi + printf '%s' "${network}" +} + +backup_mysql_root_query() { + backup_compose exec -T mysql sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B "$MYSQL_DATABASE" -e "$1"' \ + sh "$1" +} + +backup_validate_dir() { + local dir="$1" + if [[ "${dir}" != /data/* || "${dir}" == "/data" ]]; then + backup_error "备份目录必须是 /data 之下的具体绝对路径,当前值:${dir}" + exit 1 + fi +} + +backup_lock_or_exit() { + local path="$1" + local wait_seconds="${2:-${BACKUP_LOCK_WAIT_SECONDS:-0}}" + backup_require_cmd flock + mkdir -p "$(dirname "${path}")" + exec 9>"${path}" + if [[ ! "${wait_seconds}" =~ ^[0-9]+$ ]]; then + backup_error "锁等待时间必须是非负整数:${wait_seconds}" + exit 1 + fi + if ! flock -w "${wait_seconds}" 9; then + backup_error "已有同类备份任务在执行,拒绝并发运行" + exit 1 + fi +} + +backup_server_uuid() { + local value + value="$(backup_mysql_root_query 'SELECT @@server_uuid;' 2>/dev/null || true)" + if [[ ! "${value}" =~ ^[0-9A-Fa-f-]{36}$ ]]; then + backup_error "无法读取 MySQL server_uuid" + exit 1 + fi + printf '%s' "${value,,}" +} + +backup_binlog_index() { + local filename="$1" + if [[ "${filename}" =~ ^mysql-bin\.([0-9]{6,})$ ]]; then + printf '%d' "$((10#${BASH_REMATCH[1]}))" + return + fi + backup_error "不支持的 binlog 文件名:${filename}" + exit 1 +} + +backup_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + return + fi + shasum -a 256 "$1" | awk '{print $1}' +} + +backup_file_size() { + if [[ "$(uname)" == 'Darwin' ]]; then + stat -f%z "$1" + else + stat -c%s "$1" + fi +} + +backup_encrypt_file() { + local source="$1" + local destination="$2" + local passphrase + passphrase="$(backup_require_env BACKUP_PASSPHRASE)" + BACKUP_PASSPHRASE="${passphrase}" openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt \ + -pass env:BACKUP_PASSPHRASE \ + -in "${source}" \ + -out "${destination}" + unset passphrase +} + +backup_decrypt_file() { + local source="$1" + local destination="$2" + local passphrase + passphrase="$(backup_require_env BACKUP_PASSPHRASE)" + BACKUP_PASSPHRASE="${passphrase}" openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \ + -pass env:BACKUP_PASSPHRASE \ + -in "${source}" \ + -out "${destination}" + unset passphrase +} + +backup_oss_upload_kms() { + local source="$1" + local destination="$2" + local kms_key_id + kms_key_id="$(backup_require_env BACKUP_KMS_KEY_ID)" + ossutil cp "${source}" "${destination}" \ + --encryption KMS --kms-master-key-id "${kms_key_id}" +} + +backup_validate_oss_uri() { + local uri="$1" + if [[ ! "${uri}" =~ ^oss://[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + backup_error "BACKUP_OSS_URI 必须是 oss://bucket 格式,当前值:${uri}" + exit 1 + fi +} + +backup_oss_download() { + local source="$1" + local destination="$2" + ossutil cp "${source}" "${destination}" +} + +backup_write_complete_marker() { + local path="$1" + local payload_name="$2" + local payload_sha256="$3" + local payload_size="$4" + local server_uuid="$5" + { + printf 'format_version=1\n' + printf 'created_at=%s\n' "$(date -Iseconds)" + printf 'server_uuid=%s\n' "${server_uuid}" + printf 'payload=%s\n' "${payload_name}" + printf 'payload_sha256=%s\n' "${payload_sha256}" + printf 'payload_size_bytes=%s\n' "${payload_size}" + } > "${path}" +} diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh new file mode 100755 index 0000000..bae1cc4 --- /dev/null +++ b/scripts/backup-online.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# MySQL 在线全量物理备份。XtraBackup 在业务读写期间运行,备份只在 +# 上传 payload、manifest、checksum 后最后写入 complete.env,恢复程序只接受 +# 带 complete.env 的备份集。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +cleanup_dir='' +cleanup() { + if [[ -n "${cleanup_dir}" && -d "${cleanup_dir}" ]]; then + rm -rf -- "${cleanup_dir}" + fi +} +trap cleanup EXIT + +require_backup_config() { + backup_require_env BACKUP_DIR >/dev/null + backup_require_env BACKUP_OSS_URI >/dev/null + backup_require_env BACKUP_PASSPHRASE >/dev/null + backup_require_env BACKUP_KMS_KEY_ID >/dev/null + backup_require_env BACKUP_MYSQL_USER >/dev/null + backup_require_env BACKUP_MYSQL_PASSWORD >/dev/null +} + +upload_backup_set() { + local local_dir="$1" + local level="$2" + local ts="$3" + local server_uuid="$4" + local oss_uri="$5" + local remote_dir="${oss_uri}/mysql/full/${level}/${server_uuid}/${ts}" + + backup_log "上传 ${level} 全量备份到 ${remote_dir}" + backup_oss_upload_kms "${local_dir}/mysql-full.tgz.enc" "${remote_dir}/mysql-full.tgz.enc" + backup_oss_upload_kms "${local_dir}/manifest.env" "${remote_dir}/manifest.env" + backup_oss_upload_kms "${local_dir}/mysql-full.tgz.enc.sha256" "${remote_dir}/mysql-full.tgz.enc.sha256" + # complete.env 必须最后上传,作为远端原子完成标识。 + backup_oss_upload_kms "${local_dir}/complete.env" "${remote_dir}/complete.env" +} + +full_backup() { + backup_require_cmd docker + backup_require_cmd openssl + backup_require_cmd ossutil + require_backup_config + + local backup_dir oss_uri mysql_cid network server_uuid backup_user backup_password ts tmp_dir target_dir + backup_dir="$(backup_require_env BACKUP_DIR)" + backup_validate_dir "${backup_dir}" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + backup_validate_oss_uri "${oss_uri}" + backup_lock_or_exit "${backup_dir}/.full.lock" + mysql_cid="$(backup_mysql_container_id)" + network="$(backup_mysql_network "${mysql_cid}")" + server_uuid="$(backup_server_uuid)" + backup_user="$(backup_require_env BACKUP_MYSQL_USER)" + backup_password="$(backup_require_env BACKUP_MYSQL_PASSWORD)" + ts="$(date +%Y%m%d_%H%M%S)" + tmp_dir="${backup_dir}/.tmp-full-${ts}-$$" + target_dir="${backup_dir}/full/daily/${ts}" + cleanup_dir="${tmp_dir}" + mkdir -p "${tmp_dir}/xtrabackup" + + cat > "${tmp_dir}/xtrabackup-client.cnf" </dev/null || true)" + } > "${tmp_dir}/manifest.env" + + # 关闭包含 XtraBackup 位点的当前 binlog 并归档。全量备份只有在该文件 + # 已有远端 complete 标识后才会被标记完成。 + BACKUP_LOCK_WAIT_SECONDS=120 "${SCRIPT_DIR}/archive-binlog.sh" archive + if ! find "${backup_dir}/binlog-state/${server_uuid}" -maxdepth 1 -type f \ + -name "${binlog_file}.*.state" -print -quit | grep -q .; then + backup_error "XtraBackup 位点 ${binlog_file}:${binlog_pos} 尚未完成 binlog 归档" + exit 1 + fi + + tar czf "${tmp_dir}/mysql-full.tgz" \ + -C "${tmp_dir}/xtrabackup/data" . \ + -C "${tmp_dir}" manifest.env + backup_encrypt_file "${tmp_dir}/mysql-full.tgz" "${tmp_dir}/mysql-full.tgz.enc" + rm -f "${tmp_dir}/mysql-full.tgz" + + local payload_sha payload_size + payload_sha="$(backup_sha256 "${tmp_dir}/mysql-full.tgz.enc")" + payload_size="$(backup_file_size "${tmp_dir}/mysql-full.tgz.enc")" + printf '%s %s\n' "${payload_sha}" 'mysql-full.tgz.enc' > "${tmp_dir}/mysql-full.tgz.enc.sha256" + backup_write_complete_marker "${tmp_dir}/complete.env" 'mysql-full.tgz.enc' "${payload_sha}" "${payload_size}" "${server_uuid}" + + mkdir -p "${target_dir}" + mv "${tmp_dir}/mysql-full.tgz.enc" "${target_dir}/" + mv "${tmp_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/" + mv "${tmp_dir}/manifest.env" "${target_dir}/" + mv "${tmp_dir}/complete.env" "${target_dir}/" + rm -rf "${tmp_dir}/xtrabackup" + cleanup_dir='' + rmdir "${tmp_dir}" + + upload_backup_set "${target_dir}" daily "${ts}" "${server_uuid}" "${oss_uri}" + if [[ "$(date +%u)" == '7' ]]; then + mkdir -p "${backup_dir}/full/weekly/${ts}" + ln "${target_dir}/mysql-full.tgz.enc" "${backup_dir}/full/weekly/${ts}/mysql-full.tgz.enc" + cp "${target_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/manifest.env" "${target_dir}/complete.env" "${backup_dir}/full/weekly/${ts}/" + upload_backup_set "${target_dir}" weekly "${ts}" "${server_uuid}" "${oss_uri}" + fi + if [[ "$(date +%d)" == '01' ]]; then + mkdir -p "${backup_dir}/full/monthly/${ts}" + ln "${target_dir}/mysql-full.tgz.enc" "${backup_dir}/full/monthly/${ts}/mysql-full.tgz.enc" + cp "${target_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/manifest.env" "${target_dir}/complete.env" "${backup_dir}/full/monthly/${ts}/" + upload_backup_set "${target_dir}" monthly "${ts}" "${server_uuid}" "${oss_uri}" + fi + backup_log "全量热备完成:${target_dir},位点 ${binlog_file}:${binlog_pos}" +} + +prune_level() { + local base="$1" + local days="$2" + [[ -d "${base}" ]] || return + find "${base}" -mindepth 1 -maxdepth 1 -type d -mtime "+${days}" -print0 | + while IFS= read -r -d '' dir; do + backup_log "清理过期本地备份:${dir}" + rm -rf -- "${dir}" + done +} + +prune_backups() { + local backup_dir daily weekly monthly + backup_dir="$(backup_require_env BACKUP_DIR)" + backup_validate_dir "${backup_dir}" + backup_lock_or_exit "${backup_dir}/.full.lock" + daily="$(backup_env_value BACKUP_KEEP_DAILY)"; daily="${daily:-14}" + weekly="$(backup_env_value BACKUP_KEEP_WEEKLY)"; weekly="${weekly:-8}" + monthly="$(backup_env_value BACKUP_KEEP_MONTHLY)"; monthly="${monthly:-12}" + [[ "${daily}" =~ ^[0-9]+$ && "${weekly}" =~ ^[0-9]+$ && "${monthly}" =~ ^[0-9]+$ ]] || { + backup_error 'BACKUP_KEEP_* 必须是非负整数' + exit 1 + } + prune_level "${backup_dir}/full/daily" "${daily}" + prune_level "${backup_dir}/full/weekly" "$((weekly * 7))" + prune_level "${backup_dir}/full/monthly" "$((monthly * 31))" +} + +status() { + local backup_dir oss_uri + backup_dir="$(backup_require_env BACKUP_DIR)" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + backup_validate_oss_uri "${oss_uri}" + backup_log '本地最近完整全量备份:' + find "${backup_dir}/full" -name complete.env -print 2>/dev/null | sort | tail -8 || true + backup_log 'OSS 最近完整全量备份:' + ossutil ls "${oss_uri}/mysql/full/" 2>/dev/null | grep 'complete.env' | tail -8 || true + backup_log 'OSS 最近 binlog 完成标识:' + ossutil ls "${oss_uri}/mysql/binlog/" -r 2>/dev/null | grep 'complete.env' | tail -8 || true +} + +case "${1:-}" in + full) full_backup ;; + prune) prune_backups ;; + status) status ;; + *) + printf '用法: %s {full|prune|status}\n' "$0" >&2 + exit 1 + ;; +esac diff --git a/scripts/backup-prod.sh b/scripts/backup-prod.sh deleted file mode 100755 index e58f858..0000000 --- a/scripts/backup-prod.sh +++ /dev/null @@ -1,533 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prod.yml" -BACKEND_ENV="${ROOT_DIR}/backend/.env" -BACKUP_ROOT="${BACKUP_ROOT:-${ROOT_DIR}/backups}" -HELPER_IMAGE="${BACKUP_HELPER_IMAGE:-redis:7.4-alpine}" -if [[ "${BACKUP_ROOT}" != /* ]]; then - BACKUP_ROOT="${ROOT_DIR}/${BACKUP_ROOT}" -fi - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[1;34m' -NC='\033[0m' - -log() { - printf "${BLUE}[backup]${NC} %s\n" "$*" >&2 -} - -log_success() { - printf "${GREEN}[backup]${NC} %s\n" "$*" >&2 -} - -log_warn() { - printf "${YELLOW}[backup]${NC} %s\n" "$*" >&2 -} - -log_error() { - printf "${RED}[backup]${NC} %s\n" "$*" >&2 -} - -need_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - log_error "缺少命令:$1" - exit 1 - fi -} - -absolute_dir() { - local dir="$1" - ( - cd "${dir}" - pwd -P - ) -} - -show_help() { - cat << EOF -用法: ./scripts/backup-prod.sh <命令> [选项] - -命令: - backup 创建完整生产备份 - restore <备份目录> 从备份目录恢复生产数据(需要 --yes) - list 列出本机备份目录 - -选项: - --yes 确认执行破坏性恢复,只用于 restore - --no-stop backup 时不主动停止 backend/caddy,适合只读低风险窗口(不推荐) - -h, --help 显示帮助 - -环境变量: - BACKUP_ROOT=/path 备份根目录,默认 ./backups - BACKUP_HELPER_IMAGE=img 用于打包/还原 Docker volume 的镜像,默认 redis:7.4-alpine - -备份内容: - - MySQL 逻辑备份 mysql.sql - - MinIO volume: minio_data.tgz - - Redis volume: redis_data.tgz - - Caddy 证书/配置 volume: caddy_data.tgz, caddy_config.tgz - - backend/.env、deploy 配置、backend/logs - - 当前 Git commit、Docker Compose 状态、goose 迁移状态 - -示例: - ./scripts/backup-prod.sh backup - ./scripts/backup-prod.sh list - ./scripts/backup-prod.sh restore backups/20260618_153000 --yes -EOF -} - -compose() { - local app_env caddy_domain caddy_email admin_base_path - app_env="$(env_value APP_ENV)" - caddy_domain="${CADDY_DOMAIN:-$(env_value CADDY_DOMAIN)}" - caddy_email="${CADDY_EMAIL:-$(env_value CADDY_EMAIL)}" - admin_base_path="${VITE_ADMIN_BASE_PATH:-$(env_value VITE_ADMIN_BASE_PATH)}" - - if [[ "${app_env}" == "production" && (-z "${caddy_domain}" || -z "${caddy_email}") ]]; then - log_error "APP_ENV=production 时必须配置 CADDY_DOMAIN 和 CADDY_EMAIL" - exit 1 - fi - - CADDY_DOMAIN="${caddy_domain:-backup.localhost}" \ - CADDY_EMAIL="${caddy_email:-backup@example.com}" \ - VITE_ADMIN_BASE_PATH="${admin_base_path:-/manage-7x9k2p}" \ - docker compose --env-file "${BACKEND_ENV}" -f "${COMPOSE_FILE}" "$@" -} - -require_env_file() { - if [[ ! -f "${BACKEND_ENV}" ]]; then - log_error "未找到 backend/.env,无法读取生产环境变量" - exit 1 - fi -} - -env_value() { - local key="$1" - awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "${BACKEND_ENV}" -} - -require_env() { - local key="$1" - local value - value="$(env_value "${key}")" - if [[ -z "${value}" ]]; then - log_error "backend/.env 缺少或未填写 ${key}" - exit 1 - fi - printf "%s" "${value}" -} - -container_id() { - local service="$1" - local cid - cid="$(compose ps -a -q "${service}" 2>/dev/null || true)" - if [[ -z "${cid}" ]]; then - log "创建 ${service} 容器以挂载 volume..." - compose create "${service}" >/dev/null - cid="$(compose ps -a -q "${service}" 2>/dev/null || true)" - fi - if [[ -z "${cid}" ]]; then - log_error "无法找到 ${service} 容器" - exit 1 - fi - printf "%s" "${cid}" -} - -service_was_running() { - local service="$1" - local cid status - cid="$(compose ps -q "${service}" 2>/dev/null || true)" - if [[ -z "${cid}" ]]; then - return 1 - fi - status="$(docker inspect -f '{{.State.Status}}' "${cid}" 2>/dev/null || true)" - [[ "${status}" == "running" ]] -} - -start_services() { - if [[ "$#" -eq 0 ]]; then - return - fi - log "启动服务:$*" - compose up -d "$@" -} - -stop_services() { - if [[ "$#" -eq 0 ]]; then - return - fi - log "停止服务:$*" - compose stop "$@" -} - -start_recorded_services() { - if [[ "$#" -eq 0 ]]; then - return - fi - start_services "$@" -} - -wait_service_ready() { - local service="$1" - local label="$2" - local timeout="${3:-120}" - local attempt cid status - - log "等待 ${label} 就绪..." - for ((attempt = 1; attempt <= timeout; attempt++)); do - cid="$(compose ps -q "${service}" 2>/dev/null || true)" - if [[ -n "${cid}" ]]; then - status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${cid}" 2>/dev/null || true)" - if [[ "${status}" == "healthy" || "${status}" == "running" ]]; then - log_success "${label} 已就绪" - return - fi - fi - sleep 1 - done - - log_error "${label} 启动超时" - compose logs --tail=80 "${service}" || true - exit 1 -} - -remember_running() { - local service="$1" - local result_array_name="$2" - if service_was_running "${service}"; then - eval "${result_array_name}+=(\"${service}\")" - fi -} - -tar_from_service_volume() { - local service="$1" - local source_dir="$2" - local backup_dir="$3" - local output_name="$4" - local cid - cid="$(container_id "${service}")" - log "备份 ${service}:${source_dir} -> ${output_name}" - docker run --rm \ - --volumes-from "${cid}:ro" \ - -v "${backup_dir}:/backup" \ - "${HELPER_IMAGE}" \ - sh -c "cd '${source_dir}' && tar czf '/backup/${output_name}' ." -} - -restore_service_volume() { - local service="$1" - local target_dir="$2" - local backup_dir="$3" - local input_name="$4" - local cid - if [[ ! -f "${backup_dir}/${input_name}" ]]; then - log_warn "跳过 ${service}:${target_dir},备份文件不存在:${input_name}" - return - fi - cid="$(container_id "${service}")" - log "还原 ${input_name} -> ${service}:${target_dir}" - docker run --rm \ - --volumes-from "${cid}" \ - -v "${backup_dir}:/backup:ro" \ - "${HELPER_IMAGE}" \ - sh -c "rm -rf '${target_dir}'/* '${target_dir}'/.[!.]* '${target_dir}'/..?* 2>/dev/null || true; tar xzf '/backup/${input_name}' -C '${target_dir}'" -} - -copy_if_exists() { - local source="$1" - local target="$2" - if [[ -e "${source}" ]]; then - cp -a "${source}" "${target}" - fi -} - -write_manifest() { - local backup_dir="$1" - { - printf "created_at=%s\n" "$(date -Iseconds)" - printf "host=%s\n" "$(hostname)" - printf "git_commit=%s\n" "$(git -C "${ROOT_DIR}" rev-parse HEAD 2>/dev/null || true)" - printf "git_branch=%s\n" "$(git -C "${ROOT_DIR}" branch --show-current 2>/dev/null || true)" - printf "mysql_database=%s\n" "$(require_env MYSQL_DATABASE)" - printf "storage_bucket=%s\n" "$(require_env STORAGE_BUCKET)" - } > "${backup_dir}/manifest.env" - - git -C "${ROOT_DIR}" log --oneline -5 > "${backup_dir}/git-log.txt" 2>/dev/null || true - compose ps > "${backup_dir}/docker-compose-ps.txt" 2>/dev/null || true - compose config > "${backup_dir}/docker-compose.config.rendered.yml" 2>/dev/null || true -} - -backup_mysql() { - local backup_dir="$1" - log "备份 MySQL -> mysql.sql" - compose exec -T mysql sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump -uroot --single-transaction --routines --triggers --events --hex-blob --default-character-set=utf8mb4 "$MYSQL_DATABASE"' \ - > "${backup_dir}/mysql.sql" - compose exec -T mysql sh -c 'mysql --version' > "${backup_dir}/mysql-version.txt" 2>/dev/null || true -} - -backup_goose_status() { - local backup_dir="$1" - log "记录 goose 迁移状态" - compose run --rm --no-deps backend /app/goose -dir /app/migrations mysql "$(goose_dsn "$(require_env MYSQL_DSN)")" status \ - > "${backup_dir}/goose-status.txt" 2>&1 || true -} - -goose_dsn() { - local dsn="$1" - if [[ "${dsn}" != *"multiStatements="* ]]; then - if [[ "${dsn}" == *"?"* ]]; then - dsn="${dsn}&multiStatements=true" - else - dsn="${dsn}?multiStatements=true" - fi - fi - printf "%s" "${dsn}" -} - -backup_files() { - local backup_dir="$1" - log "备份配置文件" - mkdir -p "${backup_dir}/config" - copy_if_exists "${BACKEND_ENV}" "${backup_dir}/config/backend.env" - copy_if_exists "${COMPOSE_FILE}" "${backup_dir}/config/docker-compose.prod.yml" - copy_if_exists "${ROOT_DIR}/deploy/caddy/Caddyfile" "${backup_dir}/config/Caddyfile" - copy_if_exists "${ROOT_DIR}/deploy/mysql/my.cnf" "${backup_dir}/config/mysql.my.cnf" - - if [[ -d "${ROOT_DIR}/backend/logs" ]]; then - log "备份后端日志 -> backend_logs.tgz" - tar czf "${backup_dir}/backend_logs.tgz" -C "${ROOT_DIR}/backend" logs - fi -} - -restore_files() { - local backup_dir="$1" - local safety_dir="${BACKUP_ROOT}/pre-restore-files-$(date +%Y%m%d_%H%M%S)" - mkdir -p "${safety_dir}" - - log "恢复配置文件(当前文件会先备份到 ${safety_dir})" - copy_if_exists "${BACKEND_ENV}" "${safety_dir}/backend.env" - copy_if_exists "${COMPOSE_FILE}" "${safety_dir}/docker-compose.prod.yml" - copy_if_exists "${ROOT_DIR}/deploy/caddy/Caddyfile" "${safety_dir}/Caddyfile" - copy_if_exists "${ROOT_DIR}/deploy/mysql/my.cnf" "${safety_dir}/mysql.my.cnf" - - copy_if_exists "${backup_dir}/config/backend.env" "${BACKEND_ENV}" - copy_if_exists "${backup_dir}/config/docker-compose.prod.yml" "${COMPOSE_FILE}" - copy_if_exists "${backup_dir}/config/Caddyfile" "${ROOT_DIR}/deploy/caddy/Caddyfile" - copy_if_exists "${backup_dir}/config/mysql.my.cnf" "${ROOT_DIR}/deploy/mysql/my.cnf" - - if [[ -f "${backup_dir}/backend_logs.tgz" ]]; then - log "恢复后端日志" - mkdir -p "${ROOT_DIR}/backend" - rm -rf "${ROOT_DIR}/backend/logs" - tar xzf "${backup_dir}/backend_logs.tgz" -C "${ROOT_DIR}/backend" - fi -} - -prepare_restore_env() { - local backup_dir="$1" - if [[ ! -f "${BACKEND_ENV}" ]]; then - if [[ ! -f "${backup_dir}/config/backend.env" ]]; then - log_error "当前缺少 backend/.env,备份中也没有 config/backend.env,无法恢复" - exit 1 - fi - log_warn "当前缺少 backend/.env,先从备份恢复环境变量文件" - mkdir -p "$(dirname "${BACKEND_ENV}")" - cp -a "${backup_dir}/config/backend.env" "${BACKEND_ENV}" - fi - if [[ ! -f "${COMPOSE_FILE}" && -f "${backup_dir}/config/docker-compose.prod.yml" ]]; then - log_warn "当前缺少 deploy/docker-compose.prod.yml,先从备份恢复 Compose 文件" - mkdir -p "$(dirname "${COMPOSE_FILE}")" - cp -a "${backup_dir}/config/docker-compose.prod.yml" "${COMPOSE_FILE}" - fi - require_env_file -} - -restore_mysql() { - local backup_dir="$1" - local database - database="$(require_env MYSQL_DATABASE)" - if [[ ! "${database}" =~ ^[A-Za-z0-9_]+$ ]]; then - log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${database}" - exit 1 - fi - if [[ ! -f "${backup_dir}/mysql.sql" ]]; then - log_error "缺少 MySQL 备份文件:${backup_dir}/mysql.sql" - exit 1 - fi - - start_services mysql - wait_service_ready mysql "MySQL" - log "重建数据库 ${database}" - compose exec -T mysql sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot -e "DROP DATABASE IF EXISTS \`$MYSQL_DATABASE\`; CREATE DATABASE \`$MYSQL_DATABASE\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"' - log "导入 MySQL 备份" - compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot "$MYSQL_DATABASE"' < "${backup_dir}/mysql.sql" -} - -backup_all() { - require_env_file - need_cmd docker - local no_stop="$1" - local backup_dir="${BACKUP_ROOT}/$(date +%Y%m%d_%H%M%S)" - local restart_services=() - local backup_ok=0 - - backup_failure_cleanup() { - local exit_code=$? - trap - ERR - if [[ "${no_stop}" == "0" && "${backup_ok}" != "1" ]]; then - log_warn "备份失败,尝试恢复备份前运行的服务" - start_recorded_services "${restart_services[@]+"${restart_services[@]}"}" || true - fi - exit "${exit_code}" - } - trap backup_failure_cleanup ERR - - umask 077 - mkdir -p "${backup_dir}" - chmod 700 "${backup_dir}" - log "备份目录:${backup_dir}" - - if [[ "${no_stop}" == "0" ]]; then - remember_running caddy restart_services - remember_running backend restart_services - stop_services caddy backend - else - log_warn "已开启 --no-stop,备份期间仍可能有写入,建议只在低风险窗口使用" - fi - - start_services mysql - wait_service_ready mysql "MySQL" - backup_mysql "${backup_dir}" - backup_goose_status "${backup_dir}" - - if [[ "${no_stop}" == "0" ]]; then - remember_running minio restart_services - remember_running redis restart_services - stop_services minio redis caddy - fi - - tar_from_service_volume minio /data "${backup_dir}" minio_data.tgz - tar_from_service_volume redis /data "${backup_dir}" redis_data.tgz - tar_from_service_volume caddy /data "${backup_dir}" caddy_data.tgz - tar_from_service_volume caddy /config "${backup_dir}" caddy_config.tgz - backup_files "${backup_dir}" - write_manifest "${backup_dir}" - - if [[ "${no_stop}" == "0" ]]; then - start_recorded_services "${restart_services[@]+"${restart_services[@]}"}" - fi - - backup_ok=1 - trap - ERR - log_success "备份完成:${backup_dir}" - log_warn "备份包含生产密钥,请离线加密保存,不要提交到 Git。" -} - -restore_all() { - need_cmd docker - local backup_dir="$1" - local yes="$2" - local restore_ok=0 - - if [[ -z "${backup_dir}" || ! -d "${backup_dir}" ]]; then - log_error "备份目录不存在:${backup_dir}" - exit 1 - fi - if [[ "${yes}" != "1" ]]; then - log_error "restore 会覆盖当前生产数据库、MinIO、Redis、Caddy volume 和配置文件。确认执行请追加 --yes" - exit 1 - fi - backup_dir="$(absolute_dir "${backup_dir}")" - - restore_failure_cleanup() { - local exit_code=$? - trap - ERR - if [[ "${restore_ok}" != "1" ]]; then - log_warn "恢复失败,尝试启动当前配置下的服务,便于继续排查" - compose up -d || true - fi - exit "${exit_code}" - } - trap restore_failure_cleanup ERR - - prepare_restore_env "${backup_dir}" - log_warn "即将从 ${backup_dir} 恢复,当前生产数据会被覆盖。" - stop_services backend caddy minio redis - restore_files "${backup_dir}" - restore_mysql "${backup_dir}" - - restore_service_volume minio /data "${backup_dir}" minio_data.tgz - restore_service_volume redis /data "${backup_dir}" redis_data.tgz - restore_service_volume caddy /data "${backup_dir}" caddy_data.tgz - restore_service_volume caddy /config "${backup_dir}" caddy_config.tgz - - log "启动生产服务" - compose up -d - restore_ok=1 - trap - ERR - log_success "恢复完成。建议立刻执行健康检查并抽查图片/二维码/订单数据。" -} - -list_backups() { - if [[ ! -d "${BACKUP_ROOT}" ]]; then - log_warn "备份目录不存在:${BACKUP_ROOT}" - return - fi - find "${BACKUP_ROOT}" -mindepth 2 -maxdepth 2 -type f -name manifest.env -exec dirname {} \; | sort -} - -main() { - local command="${1:-}" - local no_stop=0 - local yes=0 - local restore_dir="" - - if [[ -z "${command}" || "${command}" == "-h" || "${command}" == "--help" ]]; then - show_help - exit 0 - fi - shift || true - - case "${command}" in - backup) - while [[ $# -gt 0 ]]; do - case "$1" in - --no-stop) no_stop=1; shift ;; - -h|--help) show_help; exit 0 ;; - *) log_error "未知选项:$1"; show_help; exit 1 ;; - esac - done - backup_all "${no_stop}" - ;; - restore) - restore_dir="${1:-}" - if [[ $# -gt 0 ]]; then - shift - fi - while [[ $# -gt 0 ]]; do - case "$1" in - --yes) yes=1; shift ;; - -h|--help) show_help; exit 0 ;; - *) log_error "未知选项:$1"; show_help; exit 1 ;; - esac - done - restore_all "${restore_dir}" "${yes}" - ;; - list) - list_backups - ;; - *) - log_error "未知命令:${command}" - show_help - exit 1 - ;; - esac -} - -main "$@" diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 8fee5fa..cec8155 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -146,23 +146,28 @@ validate_env() { exit 1 fi - local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid - local minio_root_user minio_root_password storage_access_key_id storage_secret_access_key + local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid backup_dir backup_restore_dir backup_oss_uri backup_user app_env="$(require_env APP_ENV)" caddy_domain="$(require_env CADDY_DOMAIN)" caddy_email="$(require_env CADDY_EMAIL)" mysql_dsn="$(require_env MYSQL_DSN)" redis_addr="$(require_env REDIS_ADDR)" storage_endpoint="$(require_env STORAGE_ENDPOINT)" - require_env MYSQL_DATABASE >/dev/null + local mysql_database + mysql_database="$(require_env MYSQL_DATABASE)" require_env MYSQL_ROOT_PASSWORD >/dev/null require_env MYSQL_USER >/dev/null require_env MYSQL_PASSWORD >/dev/null require_env JWT_SECRET >/dev/null - minio_root_user="$(require_env MINIO_ROOT_USER)" - minio_root_password="$(require_env MINIO_ROOT_PASSWORD)" - storage_access_key_id="$(require_env STORAGE_ACCESS_KEY_ID)" - storage_secret_access_key="$(require_env STORAGE_SECRET_ACCESS_KEY)" + require_env STORAGE_ACCESS_KEY_ID >/dev/null + require_env STORAGE_SECRET_ACCESS_KEY >/dev/null + backup_dir="$(require_env BACKUP_DIR)" + backup_restore_dir="$(require_env BACKUP_RESTORE_DIR)" + backup_oss_uri="$(require_env BACKUP_OSS_URI)" + require_env BACKUP_PASSPHRASE >/dev/null + require_env BACKUP_KMS_KEY_ID >/dev/null + backup_user="$(require_env BACKUP_MYSQL_USER)" + require_env BACKUP_MYSQL_PASSWORD >/dev/null backend_uid="$(require_env BACKEND_UID)" backend_gid="$(require_env BACKEND_GID)" @@ -197,14 +202,36 @@ validate_env() { exit 1 fi if [[ "${storage_endpoint}" == *"127.0.0.1"* || "${storage_endpoint}" == *"localhost"* ]]; then - log_error "STORAGE_ENDPOINT 仍指向本机,请改为 http://minio:9000" + log_error "STORAGE_ENDPOINT 仍指向本机,请改为阿里云 OSS Endpoint(如 https://oss-cn-hangzhou.aliyuncs.com)" exit 1 fi - if [[ "${storage_endpoint}" == "http://minio:9000" || "${storage_endpoint}" == "minio:9000" ]]; then - if [[ "${storage_access_key_id}" != "${minio_root_user}" || "${storage_secret_access_key}" != "${minio_root_password}" ]]; then - log_error "使用内置 MinIO 时,STORAGE_ACCESS_KEY_ID/SECRET_ACCESS_KEY 必须和 MINIO_ROOT_USER/ROOT_PASSWORD 保持一致" - exit 1 - fi + if [[ "${storage_endpoint}" == *"minio"* ]]; then + log_error "STORAGE_ENDPOINT 仍指向已下线的 MinIO,请改为阿里云 OSS Endpoint" + exit 1 + fi + if [[ ! "${storage_endpoint}" =~ ^https://oss-[A-Za-z0-9-]+\.aliyuncs\.com/?$ ]]; then + log_error "STORAGE_ENDPOINT 必须是阿里云 OSS HTTPS Endpoint,当前值:${storage_endpoint}" + exit 1 + fi + if [[ "${backup_dir}" != /data/* || "${backup_dir}" == "/data" ]]; then + log_error "BACKUP_DIR 必须是 /data 之下的具体绝对路径,当前值:${backup_dir}" + exit 1 + fi + if [[ "${backup_restore_dir}" != /data/* || "${backup_restore_dir}" == "/data" || "${backup_restore_dir}" == "${backup_dir}" ]]; then + log_error "BACKUP_RESTORE_DIR 必须是与 BACKUP_DIR 不同的 /data 子目录,当前值:${backup_restore_dir}" + exit 1 + fi + if [[ ! "${backup_oss_uri}" =~ ^oss://[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + log_error "BACKUP_OSS_URI 必须是 oss://bucket 格式,当前值:${backup_oss_uri}" + exit 1 + fi + if [[ ! "${backup_user}" =~ ^[A-Za-z0-9_]{1,32}$ ]]; then + log_error "BACKUP_MYSQL_USER 只能包含字母、数字和下划线,长度不超过 32" + exit 1 + fi + if [[ ! "${mysql_database}" =~ ^[A-Za-z0-9_]+$ ]]; then + log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${mysql_database}" + exit 1 fi if awk -F= '/^[A-Z0-9_]+=/{ if ($0 ~ /=(change-|保持你的|你的)/) found=1 } END { exit found ? 0 : 1 }' "${BACKEND_ENV}"; then log_error "backend/.env 里还有占位值,请先替换后再部署" @@ -407,6 +434,34 @@ mysql_root_exec() { compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot "$@"' sh "$@" } +sql_string_literal() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\'/\'\'}" + printf '%s' "${value}" +} + +ensure_mysql_backup_user() { + local database backup_user backup_password escaped_password + database="$(require_env MYSQL_DATABASE)" + backup_user="$(require_env BACKUP_MYSQL_USER)" + backup_password="$(require_env BACKUP_MYSQL_PASSWORD)" + escaped_password="$(sql_string_literal "${backup_password}")" + unset backup_password + + log "配置 MySQL 在线备份账号 ${backup_user}" + printf "CREATE USER IF NOT EXISTS \`%s\`@'%%' IDENTIFIED BY '%s';\n" "${backup_user}" "${escaped_password}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "ALTER USER \`%s\`@'%%' IDENTIFIED BY '%s';\n" "${backup_user}" "${escaped_password}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "GRANT BACKUP_ADMIN, PROCESS, RELOAD, LOCK TABLES, REPLICATION CLIENT ON *.* TO \`%s\`@'%%';\n" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "GRANT SELECT ON performance_schema.log_status TO \`%s\`@'%%';\n" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "GRANT SELECT ON \`%s\`.* TO \`%s\`@'%%';\n" "${database}" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' +} + goose_dsn() { local dsn="$1" if [[ "${dsn}" != *"multiStatements="* ]]; then @@ -492,6 +547,9 @@ main() { cd "${ROOT_DIR}" need_cmd docker need_cmd curl + need_cmd openssl + need_cmd ossutil + need_cmd flock validate_env export_caddy_env prepare_log_dir @@ -534,8 +592,8 @@ main() { wait_service_healthy mysql "MySQL" wait_service_healthy redis "Redis" - wait_service_healthy minio "MinIO" wait_service_healthy caddy "Caddy" + ensure_mysql_backup_user reset_database run_migrations diff --git a/scripts/restore-online.sh b/scripts/restore-online.sh new file mode 100755 index 0000000..56d74cc --- /dev/null +++ b/scripts/restore-online.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 只用于隔离恢复演练或灾难恢复准备。它从不触碰生产 mysql 容器:prepare +# 在 BACKUP_RESTORE_DIR 下生成数据目录,start 仅启动 --network none 的临时容器。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +field_value() { + local file="$1" key="$2" + awk -F= -v key="${key}" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "${file}" +} + +require_restore_dir() { + local dir + dir="$(backup_require_env BACKUP_RESTORE_DIR)" + backup_validate_dir "${dir}" + printf '%s' "${dir}" +} + +validate_sha256() { + [[ "$1" =~ ^[0-9a-f]{64}$ ]] || { backup_error "非法 SHA-256:$1"; exit 1; } +} + +validate_server_uuid() { + [[ "$1" =~ ^[0-9a-f-]{36}$ ]] || { backup_error "非法 server_uuid:$1"; exit 1; } +} + +verify_downloaded_payload() { + local payload="$1" checksum_file="$2" complete_file="$3" + local expected actual expected_size actual_size + expected="$(field_value "${complete_file}" payload_sha256)" + expected_size="$(field_value "${complete_file}" payload_size_bytes)" + validate_sha256 "${expected}" + [[ "${expected_size}" =~ ^[0-9]+$ ]] || { backup_error '完成标识中的文件大小不合法'; exit 1; } + actual="$(backup_sha256 "${payload}")" + actual_size="$(backup_file_size "${payload}")" + [[ "${expected}" == "${actual}" && "${expected_size}" == "${actual_size}" ]] || { + backup_error "下载文件校验失败:${payload}" + exit 1 + } + grep -Fqx "${expected} $(basename "${payload}")" "${checksum_file}" || { + backup_error "校验文件内容不匹配:${checksum_file}" + exit 1 + } +} + +prepare_full() { + backup_require_cmd docker + backup_require_cmd ossutil + backup_require_cmd openssl + local complete_uri="$1" + [[ "${complete_uri}" == oss://*/complete.env ]] || { + backup_error 'prepare 参数必须是完整全量备份的 complete.env OSS URI' + exit 1 + } + local restore_dir remote_dir ts server_uuid work_dir download_dir + restore_dir="$(require_restore_dir)" + remote_dir="${complete_uri%/complete.env}" + ts="${remote_dir##*/}" + server_uuid="$(basename "$(dirname "${remote_dir}")")" + validate_server_uuid "${server_uuid}" + work_dir="${restore_dir}/${server_uuid}/${ts}" + download_dir="${work_dir}/.download" + [[ ! -e "${work_dir}" ]] || { backup_error "恢复目录已存在:${work_dir}"; exit 1; } + mkdir -p "${download_dir}" "${work_dir}/data" + trap 'rm -rf -- "${download_dir}"' EXIT + + backup_oss_download "${complete_uri}" "${download_dir}/complete.env" + [[ "$(field_value "${download_dir}/complete.env" payload)" == 'mysql-full.tgz.enc' ]] || { + backup_error '不是受支持的全量备份完成标识' + exit 1 + } + [[ "$(field_value "${download_dir}/complete.env" server_uuid)" == "${server_uuid}" ]] || { + backup_error '备份路径与完成标识的 server_uuid 不一致' + exit 1 + } + backup_oss_download "${remote_dir}/mysql-full.tgz.enc" "${download_dir}/mysql-full.tgz.enc" + backup_oss_download "${remote_dir}/mysql-full.tgz.enc.sha256" "${download_dir}/mysql-full.tgz.enc.sha256" + verify_downloaded_payload "${download_dir}/mysql-full.tgz.enc" "${download_dir}/mysql-full.tgz.enc.sha256" "${download_dir}/complete.env" + backup_decrypt_file "${download_dir}/mysql-full.tgz.enc" "${download_dir}/mysql-full.tgz" + tar xzf "${download_dir}/mysql-full.tgz" -C "${work_dir}/data" + local manifest_uuid binlog_file binlog_pos + manifest_uuid="$(field_value "${work_dir}/data/manifest.env" server_uuid)" + binlog_file="$(field_value "${work_dir}/data/manifest.env" binlog_file)" + binlog_pos="$(field_value "${work_dir}/data/manifest.env" binlog_pos)" + [[ "${manifest_uuid}" == "${server_uuid}" ]] || { backup_error '归档内 manifest 与完成标识不一致'; exit 1; } + backup_binlog_index "${binlog_file}" >/dev/null + [[ "${binlog_pos}" =~ ^[0-9]+$ ]] || { backup_error 'manifest 中 binlog 位点非法'; exit 1; } + docker run --rm -v "${work_dir}/data:/restore" "${BACKUP_XTRABACKUP_IMAGE}" \ + xtrabackup --prepare --target-dir=/restore + { + printf 'server_uuid=%s\n' "${server_uuid}" + printf 'binlog_file=%s\n' "${binlog_file}" + printf 'binlog_pos=%s\n' "${binlog_pos}" + printf 'source_complete_uri=%s\n' "${complete_uri}" + printf 'prepared_at=%s\n' "$(date -Iseconds)" + } > "${work_dir}/prepared.env" + rm -rf -- "${download_dir}" + trap - EXIT + backup_log "全量备份已 prepare:${work_dir}" + backup_log "下一步执行:$0 fetch-all ${work_dir}" +} + +fetch_binlog() { + backup_require_cmd docker + backup_require_cmd ossutil + backup_require_cmd openssl + local prepared_dir="$1" complete_uri="$2" + [[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; } + [[ "${complete_uri}" == oss://*/complete.env ]] || { backup_error 'binlog 参数必须是 complete.env OSS URI'; exit 1; } + local remote_dir temp_dir complete source_file raw_sha payload server_uuid expected_uuid target_base + remote_dir="${complete_uri%/complete.env}" + temp_dir="${prepared_dir}/.binlog-download-$$" + mkdir -p "${temp_dir}" "${prepared_dir}/binlogs" + trap 'rm -rf -- "${temp_dir}"' EXIT + backup_oss_download "${complete_uri}" "${temp_dir}/complete.env" + source_file="$(field_value "${temp_dir}/complete.env" source_binlog)" + raw_sha="$(field_value "${temp_dir}/complete.env" raw_sha256)" + payload="$(field_value "${temp_dir}/complete.env" payload)" + server_uuid="$(field_value "${temp_dir}/complete.env" server_uuid)" + expected_uuid="$(field_value "${prepared_dir}/prepared.env" server_uuid)" + backup_binlog_index "${source_file}" >/dev/null + validate_sha256 "${raw_sha}" + validate_server_uuid "${server_uuid}" + [[ "${payload}" == 'binlog.enc' && "${server_uuid}" == "${expected_uuid}" ]] || { + backup_error 'binlog 完成标识与恢复目标不匹配' + exit 1 + } + target_base="${prepared_dir}/binlogs/${source_file}.${raw_sha}" + if [[ -f "${target_base}.meta.env" && -f "${target_base}" ]]; then + rm -rf -- "${temp_dir}" + trap - EXIT + return + fi + backup_oss_download "${remote_dir}/binlog.enc" "${temp_dir}/binlog.enc" + backup_oss_download "${remote_dir}/binlog.enc.sha256" "${temp_dir}/binlog.enc.sha256" + verify_downloaded_payload "${temp_dir}/binlog.enc" "${temp_dir}/binlog.enc.sha256" "${temp_dir}/complete.env" + backup_decrypt_file "${temp_dir}/binlog.enc" "${target_base}" + docker run --rm -v "${prepared_dir}/binlogs:/binlogs:ro" mysql:8.4 \ + mysqlbinlog --verify-binlog-checksum "/binlogs/$(basename "${target_base}")" >/dev/null + mv "${temp_dir}/complete.env" "${target_base}.meta.env" + rm -rf -- "${temp_dir}" + trap - EXIT +} + +fetch_all() { + backup_require_cmd ossutil + local prepared_dir="$1" server_uuid oss_uri complete_uri + [[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; } + server_uuid="$(field_value "${prepared_dir}/prepared.env" server_uuid)" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + backup_validate_oss_uri "${oss_uri}" + while IFS= read -r complete_uri; do + [[ -n "${complete_uri}" ]] || continue + fetch_binlog "${prepared_dir}" "${complete_uri}" + done < <(ossutil ls "${oss_uri}/mysql/binlog/${server_uuid}/" -r | awk '$NF ~ /\/complete\.env$/ { print $NF }') + backup_log "已下载实例 ${server_uuid} 的全部完整 binlog" +} + +start_isolated_mysql() { + backup_require_cmd docker + local prepared_dir="$1" name="${2:-}" + [[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; } + if [[ -z "${name}" ]]; then + name="hfb-restore-$(basename "${prepared_dir}")" + fi + [[ "${name}" =~ ^hfb-restore-[A-Za-z0-9_-]+$ ]] || { backup_error '恢复容器名必须以 hfb-restore- 开头'; exit 1; } + [[ -z "$(docker ps -aq -f "name=^/${name}$")" ]] || { backup_error "恢复容器已存在:${name}"; exit 1; } + docker run -d --name "${name}" --network none \ + -e "MYSQL_ROOT_PASSWORD=$(backup_require_env MYSQL_ROOT_PASSWORD)" \ + -v "${prepared_dir}/data:/var/lib/mysql" mysql:8.4 \ + --skip-log-bin --skip-networking=1 >/dev/null + local attempt + for ((attempt = 1; attempt <= 60; attempt++)); do + if docker exec "${name}" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqladmin -uroot --protocol=socket ping' >/dev/null 2>&1; then + backup_log "隔离 MySQL 已启动:${name}" + return + fi + sleep 1 + done + backup_error "隔离 MySQL 启动失败,请执行 docker logs ${name}" + exit 1 +} + +replay_binlogs() { + backup_require_cmd docker + local prepared_dir="$1" target_container="$2" stop_datetime="${3:-}" + [[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; } + local production_cid + production_cid="$(backup_mysql_container_id)" + [[ "${target_container}" != "${production_cid}" ]] || { backup_error '禁止向生产 MySQL 回放 binlog'; exit 1; } + docker inspect "${target_container}" >/dev/null + [[ -d "${prepared_dir}/binlogs" ]] || { backup_error '尚未下载 binlog'; exit 1; } + local start_file start_pos metadata index source previous_index found=0 + start_file="$(field_value "${prepared_dir}/prepared.env" binlog_file)" + start_pos="$(field_value "${prepared_dir}/prepared.env" binlog_pos)" + mapfile -t metadata < <( + for metadata in "${prepared_dir}"/binlogs/*.meta.env; do + [[ -f "${metadata}" ]] || continue + index="$(field_value "${metadata}" binlog_index)" + source="$(field_value "${metadata}" source_binlog)" + printf '%010d\t%s\t%s\n' "${index}" "${source}" "${metadata}" + done | sort -n + ) + [[ "${#metadata[@]}" -gt 0 ]] || { backup_error '没有可回放的 binlog'; exit 1; } + local binlog_paths=() + previous_index='' + local row path + for row in "${metadata[@]}"; do + IFS=$'\t' read -r index source metadata <<< "${row}" + if [[ "${source}" == "${start_file}" ]]; then + found=1 + fi + [[ "${found}" == '1' ]] || continue + if [[ -n "${previous_index}" && "${index}" -ne $((previous_index + 1)) ]]; then + backup_error "恢复 binlog 不连续:${previous_index} -> ${index}" + exit 1 + fi + path="/binlogs/$(basename "${metadata%.meta.env}")" + binlog_paths+=("${path}") + previous_index="${index}" + done + [[ "${found}" == '1' ]] || { backup_error "缺少全量备份起始 binlog:${start_file}"; exit 1; } + local mysqlbinlog_args=(mysqlbinlog "--start-position=${start_pos}") + if [[ -n "${stop_datetime}" ]]; then + mysqlbinlog_args+=("--stop-datetime=${stop_datetime}") + fi + mysqlbinlog_args+=("${binlog_paths[@]}") + docker run --rm -i -v "${prepared_dir}/binlogs:/binlogs:ro" mysql:8.4 "${mysqlbinlog_args[@]}" | + docker exec -i "${target_container}" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + backup_log "binlog 已回放至隔离 MySQL:${target_container}" +} + +case "${1:-}" in + prepare) [[ $# -eq 2 ]] || { backup_error '用法:prepare '; exit 1; }; prepare_full "$2" ;; + fetch-all) [[ $# -eq 2 ]] || { backup_error '用法:fetch-all '; exit 1; }; fetch_all "$2" ;; + fetch-binlog) [[ $# -eq 3 ]] || { backup_error '用法:fetch-binlog '; exit 1; }; fetch_binlog "$2" "$3" ;; + start) [[ $# -le 3 && $# -ge 2 ]] || { backup_error '用法:start [container-name]'; exit 1; }; start_isolated_mysql "$2" "${3:-}" ;; + replay) [[ $# -ge 3 && $# -le 4 ]] || { backup_error '用法:replay [YYYY-MM-DD HH:MM:SS]'; exit 1; }; replay_binlogs "$2" "$3" "${4:-}" ;; + *) + printf '用法: %s {prepare|fetch-all|fetch-binlog|start|replay} ...\n' "$0" >&2 + exit 1 + ;; +esac From 7b7d23c743f6078248052dfc2bf07ac6e3066319 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 22:32:57 +0800 Subject: [PATCH 02/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/backup-online.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh index bae1cc4..f57d277 100755 --- a/scripts/backup-online.sh +++ b/scripts/backup-online.sh @@ -190,7 +190,7 @@ status() { backup_log '本地最近完整全量备份:' find "${backup_dir}/full" -name complete.env -print 2>/dev/null | sort | tail -8 || true backup_log 'OSS 最近完整全量备份:' - ossutil ls "${oss_uri}/mysql/full/" 2>/dev/null | grep 'complete.env' | tail -8 || true + ossutil ls "${oss_uri}/mysql/full/" -r 2>/dev/null | grep 'complete.env' | tail -8 || true backup_log 'OSS 最近 binlog 完成标识:' ossutil ls "${oss_uri}/mysql/binlog/" -r 2>/dev/null | grep 'complete.env' | tail -8 || true } From eb06ec3c5f9a4caf99b22d901695630766a6bf5e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 22:38:31 +0800 Subject: [PATCH 03/70] =?UTF-8?q?=E6=94=AF=E6=8C=81OSS=E6=89=98=E7=AE=A1?= =?UTF-8?q?=E5=8A=A0=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.prod.example | 4 ++-- deploy/README.md | 5 ++--- scripts/archive-binlog.sh | 7 +++---- scripts/backup-common.sh | 13 +++++++++---- scripts/backup-online.sh | 9 ++++----- scripts/deploy-prod.sh | 1 - 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/backend/.env.prod.example b/backend/.env.prod.example index b5db876..a35306f 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -111,8 +111,8 @@ BACKUP_OSS_URI=oss://hfb-backup # 客户端加密密码短语(独立保管,与业务密钥一同纳入 KMS/密码管理)。 # 生成方式:openssl rand -hex 32 BACKUP_PASSPHRASE=change-to-random-backup-passphrase -# OSS 服务端 KMS 加密 Key ID,备份脚本强制使用,不可留空。 -BACKUP_KMS_KEY_ID=change-kms-key-id +# 可选:自有 KMS Key ID。留空时 Bucket 必须开启“OSS 完全托管”服务器端加密。 +BACKUP_KMS_KEY_ID= # XtraBackup 专用 MySQL 账号,部署脚本会幂等创建并授予最小备份权限。 BACKUP_MYSQL_USER=hfb_backup # 生成方式:openssl rand -hex 24。不要和 MySQL 应用账号或 root 密码复用。 diff --git a/deploy/README.md b/deploy/README.md index 97221c2..6aabea9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -40,7 +40,6 @@ BACKUP_DIR BACKUP_RESTORE_DIR BACKUP_OSS_URI BACKUP_PASSPHRASE -BACKUP_KMS_KEY_ID BACKUP_MYSQL_PASSWORD ``` @@ -72,11 +71,11 @@ RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要 ### 数据库在线备份与时间点恢复 -生产 MySQL 开启了 `ROW` 格式 binlog 和崩溃安全刷盘。备份期间业务持续读写:每日用 XtraBackup 做物理全量备份,每分钟轮转并归档已关闭的 binlog。备份对象先做客户端加密,再强制以 OSS KMS 加密上传;远端仅在 payload、checksum 和 manifest 都上传成功后写入 `complete.env`。 +生产 MySQL 开启了 `ROW` 格式 binlog 和崩溃安全刷盘。备份期间业务持续读写:每日用 XtraBackup 做物理全量备份,每分钟轮转并归档已关闭的 binlog。备份对象先做客户端加密,再由 Bucket 的服务器端加密保护;远端仅在 payload、checksum 和 manifest 都上传成功后写入 `complete.env`。 部署脚本会创建或更新 `BACKUP_MYSQL_USER`,该用户只有 XtraBackup 所需的全局备份权限和业务库只读权限,不开放远程 root。 -备份 Bucket 必须独立于业务 Bucket,备份 RAM 身份仅授予该 Bucket 的列举、读写对象及指定 KMS Key 的加解密权限。推荐 Bucket 开启版本控制和不可变保留策略。 +备份 Bucket 必须独立于业务 Bucket,开启“OSS 完全托管”服务器端加密和版本控制。备份 RAM 身份仅授予该 Bucket 的列举、读写对象权限;若填写 `BACKUP_KMS_KEY_ID`,再授予该 Key 的加解密权限。推荐配置不可变保留策略。 部署机需安装 `ossutil`、`openssl` 和 `flock`,并使用与 cron 相同的业务用户完成 `ossutil` 凭证配置;部署脚本会在启动服务前检查这些依赖。 diff --git a/scripts/archive-binlog.sh b/scripts/archive-binlog.sh index 2283ded..80d7c1d 100755 --- a/scripts/archive-binlog.sh +++ b/scripts/archive-binlog.sh @@ -22,7 +22,6 @@ require_archive_config() { backup_require_env BACKUP_DIR >/dev/null backup_require_env BACKUP_OSS_URI >/dev/null backup_require_env BACKUP_PASSPHRASE >/dev/null - backup_require_env BACKUP_KMS_KEY_ID >/dev/null } closed_binlogs() { @@ -91,10 +90,10 @@ archive_one() { printf 'created_at=%s\n' "$(date -Iseconds)" } > "${work_dir}/${object_id}.complete.env" - backup_oss_upload_kms "${cipher_file}" "${remote_dir}/binlog.enc" - backup_oss_upload_kms "${work_dir}/${object_id}.enc.sha256" "${remote_dir}/binlog.enc.sha256" + backup_oss_upload "${cipher_file}" "${remote_dir}/binlog.enc" + backup_oss_upload "${work_dir}/${object_id}.enc.sha256" "${remote_dir}/binlog.enc.sha256" # complete.env 最后上传,恢复程序只使用有该文件的 binlog。 - backup_oss_upload_kms "${work_dir}/${object_id}.complete.env" "${remote_dir}/complete.env" + backup_oss_upload "${work_dir}/${object_id}.complete.env" "${remote_dir}/complete.env" write_state "${state_dir}" "${filename}" "${binlog_index}" "${raw_sha}" "${cipher_sha}" "${remote_dir}" rm -f -- "${raw_file}" "${cipher_file}" "${work_dir}/${object_id}.enc.sha256" "${work_dir}/${object_id}.complete.env" } diff --git a/scripts/backup-common.sh b/scripts/backup-common.sh index 5f47e63..0e40eee 100755 --- a/scripts/backup-common.sh +++ b/scripts/backup-common.sh @@ -153,13 +153,18 @@ backup_decrypt_file() { unset passphrase } -backup_oss_upload_kms() { +backup_oss_upload() { local source="$1" local destination="$2" local kms_key_id - kms_key_id="$(backup_require_env BACKUP_KMS_KEY_ID)" - ossutil cp "${source}" "${destination}" \ - --encryption KMS --kms-master-key-id "${kms_key_id}" + kms_key_id="$(backup_env_value BACKUP_KMS_KEY_ID)" + if [[ -n "${kms_key_id}" ]]; then + ossutil cp "${source}" "${destination}" \ + --encryption KMS --kms-master-key-id "${kms_key_id}" + return + fi + # 未配置 KMS 时依赖 Bucket 已启用的 OSS 托管服务器端加密;备份载荷仍会先经过客户端加密。 + ossutil cp "${source}" "${destination}" } backup_validate_oss_uri() { diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh index f57d277..4ff4714 100755 --- a/scripts/backup-online.sh +++ b/scripts/backup-online.sh @@ -22,7 +22,6 @@ require_backup_config() { backup_require_env BACKUP_DIR >/dev/null backup_require_env BACKUP_OSS_URI >/dev/null backup_require_env BACKUP_PASSPHRASE >/dev/null - backup_require_env BACKUP_KMS_KEY_ID >/dev/null backup_require_env BACKUP_MYSQL_USER >/dev/null backup_require_env BACKUP_MYSQL_PASSWORD >/dev/null } @@ -36,11 +35,11 @@ upload_backup_set() { local remote_dir="${oss_uri}/mysql/full/${level}/${server_uuid}/${ts}" backup_log "上传 ${level} 全量备份到 ${remote_dir}" - backup_oss_upload_kms "${local_dir}/mysql-full.tgz.enc" "${remote_dir}/mysql-full.tgz.enc" - backup_oss_upload_kms "${local_dir}/manifest.env" "${remote_dir}/manifest.env" - backup_oss_upload_kms "${local_dir}/mysql-full.tgz.enc.sha256" "${remote_dir}/mysql-full.tgz.enc.sha256" + backup_oss_upload "${local_dir}/mysql-full.tgz.enc" "${remote_dir}/mysql-full.tgz.enc" + backup_oss_upload "${local_dir}/manifest.env" "${remote_dir}/manifest.env" + backup_oss_upload "${local_dir}/mysql-full.tgz.enc.sha256" "${remote_dir}/mysql-full.tgz.enc.sha256" # complete.env 必须最后上传,作为远端原子完成标识。 - backup_oss_upload_kms "${local_dir}/complete.env" "${remote_dir}/complete.env" + backup_oss_upload "${local_dir}/complete.env" "${remote_dir}/complete.env" } full_backup() { diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index cec8155..2349cca 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -165,7 +165,6 @@ validate_env() { backup_restore_dir="$(require_env BACKUP_RESTORE_DIR)" backup_oss_uri="$(require_env BACKUP_OSS_URI)" require_env BACKUP_PASSPHRASE >/dev/null - require_env BACKUP_KMS_KEY_ID >/dev/null backup_user="$(require_env BACKUP_MYSQL_USER)" require_env BACKUP_MYSQL_PASSWORD >/dev/null backend_uid="$(require_env BACKEND_UID)" From 96221d3c0073f9ebf5ef93f59fa37fb20790c79a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 23:31:17 +0800 Subject: [PATCH 04/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8DXtraBackup=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/backup-online.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh index 4ff4714..d059599 100755 --- a/scripts/backup-online.sh +++ b/scripts/backup-online.sh @@ -71,7 +71,9 @@ user=${backup_user} password=${backup_password} host=mysql EOF - chmod 600 "${tmp_dir}/xtrabackup-client.cnf" + # XtraBackup 镜像可能以非 root 用户运行;临时目录受 umask 077 保护, + # 因此配置文件需要对容器内用户可读,但不会暴露给宿主机其他用户。 + chmod 644 "${tmp_dir}/xtrabackup-client.cnf" backup_log "开始全量热备,实例 ${server_uuid}" docker run --rm \ From 4fb79cb37f95ff96b2eecc940ca41073cd692d81 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 23:32:21 +0800 Subject: [PATCH 05/70] =?UTF-8?q?=E8=A1=A5=E9=BD=90XtraBackup=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy-prod.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 2349cca..51dd780 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -457,6 +457,11 @@ ensure_mysql_backup_user() { compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' printf "GRANT SELECT ON performance_schema.log_status TO \`%s\`@'%%';\n" "${backup_user}" | compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + # MySQL 8.4 的 XtraBackup 会读取以下只读状态表,即使未启用对应组件也会探测。 + printf "GRANT SELECT ON performance_schema.replication_group_members TO \`%s\`@'%%';\n" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "GRANT SELECT ON performance_schema.keyring_component_status TO \`%s\`@'%%';\n" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' printf "GRANT SELECT ON \`%s\`.* TO \`%s\`@'%%';\n" "${database}" "${backup_user}" | compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' } From 7d7141c055632ad9b9b3045b7edbaf4e04647190 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 23:34:53 +0800 Subject: [PATCH 06/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8DXtraBackup=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=8D=B7=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/backup-online.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh index d059599..0925664 100755 --- a/scripts/backup-online.sh +++ b/scripts/backup-online.sh @@ -71,12 +71,12 @@ user=${backup_user} password=${backup_password} host=mysql EOF - # XtraBackup 镜像可能以非 root 用户运行;临时目录受 umask 077 保护, - # 因此配置文件需要对容器内用户可读,但不会暴露给宿主机其他用户。 - chmod 644 "${tmp_dir}/xtrabackup-client.cnf" + chmod 600 "${tmp_dir}/xtrabackup-client.cnf" backup_log "开始全量热备,实例 ${server_uuid}" - docker run --rm \ + # MySQL 数据卷中的 redo 文件仅 MySQL 用户和 root 可读。以 root 运行备份 + # 容器读取数据,同时保留宿主机业务用户的组,保证后续本地打包可读取备份文件。 + docker run --rm --user "0:$(id -g)" \ --network "${network}" \ --volumes-from "${mysql_cid}:ro" \ -v "${tmp_dir}/xtrabackup:/backup" \ From ca4372271aa29250f392c36d677523b6f89024d8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 23:37:15 +0800 Subject: [PATCH 07/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=B1=9E=E4=B8=BB=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/backup-online.sh | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh index 0925664..e295514 100755 --- a/scripts/backup-online.sh +++ b/scripts/backup-online.sh @@ -13,7 +13,14 @@ source "${SCRIPT_DIR}/backup-common.sh" cleanup_dir='' cleanup() { if [[ -n "${cleanup_dir}" && -d "${cleanup_dir}" ]]; then - rm -rf -- "${cleanup_dir}" + # XtraBackup 会保留源文件属主;失败时先在容器内归还临时目录属主, + # 再由运行备份的业务用户删除,避免残留无法清理的大文件。 + docker run --rm --user 0:0 \ + -v "${cleanup_dir}:/cleanup" \ + --entrypoint chown \ + "${BACKUP_XTRABACKUP_IMAGE}" \ + -R "$(id -u):$(id -g)" /cleanup >/dev/null 2>&1 || true + rm -rf -- "${cleanup_dir}" || true fi } trap cleanup EXIT @@ -74,9 +81,8 @@ EOF chmod 600 "${tmp_dir}/xtrabackup-client.cnf" backup_log "开始全量热备,实例 ${server_uuid}" - # MySQL 数据卷中的 redo 文件仅 MySQL 用户和 root 可读。以 root 运行备份 - # 容器读取数据,同时保留宿主机业务用户的组,保证后续本地打包可读取备份文件。 - docker run --rm --user "0:$(id -g)" \ + # MySQL 数据卷中的 redo 文件仅 MySQL 用户和 root 可读,因此备份容器需以 root 读取。 + docker run --rm --user 0:0 \ --network "${network}" \ --volumes-from "${mysql_cid}:ro" \ -v "${tmp_dir}/xtrabackup:/backup" \ @@ -86,6 +92,12 @@ EOF --defaults-extra-file=/etc/xtrabackup-client.cnf \ --backup \ --target-dir=/backup/data + # XtraBackup 会保留源文件属主;在隔离容器内转回业务用户,供后续打包和加密使用。 + docker run --rm --user 0:0 \ + -v "${tmp_dir}/xtrabackup:/backup" \ + --entrypoint chown \ + "${BACKUP_XTRABACKUP_IMAGE}" \ + -R "$(id -u):$(id -g)" /backup/data rm -f "${tmp_dir}/xtrabackup-client.cnf" unset backup_password From 66cb03ebceaa739ce9d4df02d7853a528df30ae3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 23:40:35 +0800 Subject: [PATCH 08/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=8A=B6=E6=80=81=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/backup-online.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh index e295514..710ff15 100755 --- a/scripts/backup-online.sh +++ b/scripts/backup-online.sh @@ -142,6 +142,10 @@ EOF printf '%s %s\n' "${payload_sha}" 'mysql-full.tgz.enc' > "${tmp_dir}/mysql-full.tgz.enc.sha256" backup_write_complete_marker "${tmp_dir}/complete.env" 'mysql-full.tgz.enc' "${payload_sha}" "${payload_size}" "${server_uuid}" + # complete.env 会作为远端最后上传的原子完成标识。仅当日备已上传成功后, + # 才将该备份移入本地正式保留目录,避免失败备份被 status 误判为完整。 + upload_backup_set "${tmp_dir}" daily "${ts}" "${server_uuid}" "${oss_uri}" + mkdir -p "${target_dir}" mv "${tmp_dir}/mysql-full.tgz.enc" "${target_dir}/" mv "${tmp_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/" @@ -151,7 +155,6 @@ EOF cleanup_dir='' rmdir "${tmp_dir}" - upload_backup_set "${target_dir}" daily "${ts}" "${server_uuid}" "${oss_uri}" if [[ "$(date +%u)" == '7' ]]; then mkdir -p "${backup_dir}/full/weekly/${ts}" ln "${target_dir}/mysql-full.tgz.enc" "${backup_dir}/full/weekly/${ts}/mysql-full.tgz.enc" From f67ee12d72f4f3e3b6595979890b044a2fbbe335 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 23:53:50 +0800 Subject: [PATCH 09/70] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E4=B8=8E=E5=AE=9A=E6=97=B6=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.prod.example | 4 + backend/internal/config/config.go | 2 + .../internal/modules/backupmonitor/handler.go | 59 ++++++++ backend/internal/modules/systemconfig/dto.go | 8 + .../internal/modules/systemconfig/handler.go | 28 ++++ .../modules/systemconfig/repository.go | 4 + .../internal/modules/systemconfig/service.go | 29 ++++ .../modules/systemconfig/service_update.go | 26 ++++ backend/internal/router/router.go | 5 + .../000050_backup_monitor_permissions.sql | 35 +++++ deploy/README.md | 13 +- deploy/docker-compose.prod.yml | 2 + frontend/src/features/admin/api/backup.ts | 32 ++++ .../features/admin/views/AdminBackupView.vue | 141 ++++++++++++++++++ frontend/src/layouts/AdminLayout.vue | 6 + frontend/src/router/adminRoutes.ts | 6 + scripts/backup-scheduler.sh | 83 +++++++++++ scripts/backup-status.sh | 106 +++++++++++++ scripts/deploy-prod.sh | 26 +++- 19 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 backend/internal/modules/backupmonitor/handler.go create mode 100644 backend/migrations/000050_backup_monitor_permissions.sql create mode 100644 frontend/src/features/admin/api/backup.ts create mode 100644 frontend/src/features/admin/views/AdminBackupView.vue create mode 100755 scripts/backup-scheduler.sh create mode 100755 scripts/backup-status.sh diff --git a/backend/.env.prod.example b/backend/.env.prod.example index a35306f..af1fe7c 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -103,6 +103,10 @@ EXTERNAL_UPLOAD_ALLOWED_IPS= # 在线备份(scripts/backup-online.sh / scripts/archive-binlog.sh)。所有项为生产必填。 # 本地备份根目录,必须是独立数据盘上的 /data 子目录;包含加密全量备份与 binlog 状态。 BACKUP_DIR=/data/backups +# 后台仅读取此目录中的状态摘要;不要将整个 BACKUP_DIR 挂入 backend。 +BACKUP_STATUS_DIR=/data/backups/status +# backend 容器内的只读状态文件路径,通常保持默认。 +BACKUP_STATUS_FILE=/var/run/hfb-backup-status/status.json # 隔离恢复演练的数据目录,必须与 BACKUP_DIR 不同;prepare 后的 MySQL 原始数据只放在这里。 BACKUP_RESTORE_DIR=/data/restore # 备份专用 OSS Bucket(与业务 Bucket 分离),ossutil 使用。 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1938142..ec59a67 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { Realname RealnameConfig Log LogConfig RateLimit RateLimitConfig + BackupStatusFile string } type StorageConfig struct { @@ -130,6 +131,7 @@ func Load() Config { Enabled: getEnvBool("RATE_LIMIT_ENABLED", true), RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300), }, + BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"), } } diff --git a/backend/internal/modules/backupmonitor/handler.go b/backend/internal/modules/backupmonitor/handler.go new file mode 100644 index 0000000..b9517eb --- /dev/null +++ b/backend/internal/modules/backupmonitor/handler.go @@ -0,0 +1,59 @@ +package backupmonitor + +import ( + "encoding/json" + "errors" + "net/http" + "os" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Status struct { + GeneratedAt string `json:"generated_at"` + Health string `json:"health"` + Schedule struct { + Enabled string `json:"enabled"` + FullHour string `json:"full_hour"` + FullMinute string `json:"full_minute"` + BinlogIntervalMinutes string `json:"binlog_interval_minutes"` + } `json:"schedule"` + LastFull struct { + LocalComplete string `json:"local_complete"` + RemoteComplete string `json:"remote_complete"` + } `json:"last_full"` + LastBinlog struct { + File string `json:"file"` + RemoteDir string `json:"remote_dir"` + } `json:"last_binlog"` + LastJob struct { + Type string `json:"type"` + Status string `json:"status"` + At string `json:"at"` + Message string `json:"message"` + } `json:"last_job"` +} + +type Handler struct{ path string } + +func NewHandler(path string) *Handler { return &Handler{path: path} } + +func (h *Handler) Status(c *gin.Context) { + content, err := os.ReadFile(h.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + response.Error(c, http.StatusServiceUnavailable, "backup_status_unavailable", "备份状态尚未生成") + return + } + response.Error(c, http.StatusInternalServerError, "backup_status_unavailable", "无法读取备份状态") + return + } + var status Status + if err := json.Unmarshal(content, &status); err != nil { + response.Error(c, http.StatusServiceUnavailable, "backup_status_invalid", "备份状态文件格式无效") + return + } + response.OK(c, status) +} diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 1523fcc..88534e4 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -17,6 +17,14 @@ type UpdateRequest struct { Description string `json:"description"` } +// BackupScheduleDTO 仅描述调度时间,不包含备份目录、OSS 地址或任何密钥。 +type BackupScheduleDTO struct { + Enabled bool `json:"enabled"` + FullHour int `json:"full_hour"` + FullMinute int `json:"full_minute"` + BinlogIntervalMinutes int `json:"binlog_interval_minutes"` +} + type HomeAnnouncementsDTO struct { Items []string `json:"items"` } diff --git a/backend/internal/modules/systemconfig/handler.go b/backend/internal/modules/systemconfig/handler.go index fb476ee..c4829fa 100644 --- a/backend/internal/modules/systemconfig/handler.go +++ b/backend/internal/modules/systemconfig/handler.go @@ -110,6 +110,34 @@ func (h *Handler) Update(c *gin.Context) { response.OK(c, item) } +func (h *Handler) BackupSchedule(c *gin.Context) { + schedule, err := h.service.BackupSchedule(c.Request.Context()) + if err != nil { + writeConfigError(c, err) + return + } + response.OK(c, schedule) +} + +func (h *Handler) UpdateBackupSchedule(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req BackupScheduleDTO + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "备份定时配置格式无效") + return + } + schedule, err := h.service.UpdateBackupSchedule(c.Request.Context(), adminID, req, auditMeta(c)) + if err != nil { + writeConfigError(c, err) + return + } + response.OK(c, schedule) +} + func auditMeta(c *gin.Context) AuditMeta { return AuditMeta{ IP: c.ClientIP(), diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index a9e29ba..fa0ad13 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -51,6 +51,10 @@ var defaultConfigs = []defaultConfig{ {Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"}, + {Key: "backup.schedule_enabled", Value: "true", Description: "在线备份定时任务是否启用"}, + {Key: "backup.full_hour", Value: "4", Description: "每日全量备份小时(Asia/Shanghai)"}, + {Key: "backup.full_minute", Value: "30", Description: "每日全量备份分钟(Asia/Shanghai)"}, + {Key: "backup.binlog_interval_minutes", Value: "1", Description: "binlog 归档间隔分钟(1-5)"}, } var adminVisibleConfigKeys = []string{ diff --git a/backend/internal/modules/systemconfig/service.go b/backend/internal/modules/systemconfig/service.go index 5e2758a..30297bd 100644 --- a/backend/internal/modules/systemconfig/service.go +++ b/backend/internal/modules/systemconfig/service.go @@ -3,6 +3,7 @@ package systemconfig import ( "context" "errors" + "strconv" ) var ( @@ -24,3 +25,31 @@ func (s *Service) List(ctx context.Context) ([]ConfigDTO, error) { } return s.repo.List(ctx) } + +func (s *Service) BackupSchedule(ctx context.Context) (*BackupScheduleDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + values := map[string]string{} + for _, item := range defaultConfigs { + if item.Key == "backup.schedule_enabled" || item.Key == "backup.full_hour" || item.Key == "backup.full_minute" || item.Key == "backup.binlog_interval_minutes" { + values[item.Key] = item.Value + } + } + for key := range values { + value, err := s.repo.FindValue(ctx, key) + if err != nil { + return nil, err + } + values[key] = value + } + hour, _ := strconv.Atoi(values["backup.full_hour"]) + minute, _ := strconv.Atoi(values["backup.full_minute"]) + interval, _ := strconv.Atoi(values["backup.binlog_interval_minutes"]) + return &BackupScheduleDTO{ + Enabled: values["backup.schedule_enabled"] == "true", + FullHour: hour, + FullMinute: minute, + BinlogIntervalMinutes: interval, + }, nil +} diff --git a/backend/internal/modules/systemconfig/service_update.go b/backend/internal/modules/systemconfig/service_update.go index 6f401f5..d6549f8 100644 --- a/backend/internal/modules/systemconfig/service_update.go +++ b/backend/internal/modules/systemconfig/service_update.go @@ -12,6 +12,10 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up if s.repo == nil { return nil, ErrDependencyUnavailable } + // 备份定时只能通过独立接口修改,避免拥有普通系统配置权限的管理员改变备份策略。 + if strings.HasPrefix(key, "backup.") { + return nil, ErrInvalidConfig + } if key == "" || (req.Value == "" && key != "integration.paddle_ocr_token") { return nil, ErrInvalidConfig } @@ -34,3 +38,25 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up } return s.repo.Update(ctx, actorID, key, req, meta) } + +func (s *Service) UpdateBackupSchedule(ctx context.Context, actorID uint64, req BackupScheduleDTO, meta AuditMeta) (*BackupScheduleDTO, error) { + if s.repo == nil || req.FullHour < 0 || req.FullHour > 23 || req.FullMinute < 0 || req.FullMinute > 59 || req.BinlogIntervalMinutes < 1 || req.BinlogIntervalMinutes > 5 { + return nil, ErrInvalidConfig + } + updates := []struct { + key string + value string + description string + }{ + {"backup.schedule_enabled", strconv.FormatBool(req.Enabled), "在线备份定时任务是否启用"}, + {"backup.full_hour", strconv.Itoa(req.FullHour), "每日全量备份小时(Asia/Shanghai)"}, + {"backup.full_minute", strconv.Itoa(req.FullMinute), "每日全量备份分钟(Asia/Shanghai)"}, + {"backup.binlog_interval_minutes", strconv.Itoa(req.BinlogIntervalMinutes), "binlog 归档间隔分钟(1-5)"}, + } + for _, item := range updates { + if _, err := s.repo.Update(ctx, actorID, item.key, UpdateRequest{Value: item.value, Description: item.description}, meta); err != nil { + return nil, err + } + } + return &req, nil +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4504235..99c74a7 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -19,6 +19,7 @@ import ( "hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/announcement" "hfb_sys/backend/internal/modules/auth" + "hfb_sys/backend/internal/modules/backupmonitor" "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/chathub" "hfb_sys/backend/internal/modules/dispute" @@ -308,6 +309,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } systemConfigService := systemconfig.NewService(systemConfigRepo) systemConfigHandler := systemconfig.NewHandler(systemConfigService) + backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile) var adminRoleRepo *adminrole.Repository if deps.DB != nil { adminRoleRepo = adminrole.NewRepository(deps.DB, deps.Redis) @@ -694,6 +696,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List) adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update) + adminRoutes.GET("/backups/status", requirePerm("backup:view"), backupMonitorHandler.Status) + adminRoutes.GET("/backups/schedule", requirePerm("backup:view"), systemConfigHandler.BackupSchedule) + adminRoutes.PUT("/backups/schedule", requirePerm("backup:schedule:update"), systemConfigHandler.UpdateBackupSchedule) adminRoutes.GET("/notifications", requirePerm("notification:view"), adminNotificationHandler.List) adminRoutes.GET("/notifications/unread-count", requirePerm("notification:view"), adminNotificationHandler.UnreadCount) adminRoutes.PUT("/notifications/read-all", requirePerm("notification:view"), adminNotificationHandler.MarkAllRead) diff --git a/backend/migrations/000050_backup_monitor_permissions.sql b/backend/migrations/000050_backup_monitor_permissions.sql new file mode 100644 index 0000000..fe6c166 --- /dev/null +++ b/backend/migrations/000050_backup_monitor_permissions.sql @@ -0,0 +1,35 @@ +-- +goose Up + +INSERT INTO permissions (code, name, resource, action) VALUES +('backup:view', '查看备份状态', 'backup', 'view'), +('backup:schedule:update', '修改备份定时', 'backup', 'schedule_update') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code = 'super_admin' AND p.code IN ('backup:view', 'backup:schedule:update'); + +INSERT INTO system_configs (`key`, `value`, description) VALUES +('backup.schedule_enabled', 'true', '在线备份定时任务是否启用'), +('backup.full_hour', '4', '每日全量备份小时(Asia/Shanghai)'), +('backup.full_minute', '30', '每日全量备份分钟(Asia/Shanghai)'), +('backup.binlog_interval_minutes', '1', 'binlog 归档间隔分钟(1-5)') +ON DUPLICATE KEY UPDATE description = VALUES(description); + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code IN ('backup:view', 'backup:schedule:update'); + +DELETE FROM permissions WHERE code IN ('backup:view', 'backup:schedule:update'); + +DELETE FROM system_configs WHERE `key` IN ( + 'backup.schedule_enabled', + 'backup.full_hour', + 'backup.full_minute', + 'backup.binlog_interval_minutes' +); diff --git a/deploy/README.md b/deploy/README.md index 6aabea9..bc3e37d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -37,6 +37,7 @@ STORAGE_SECRET_ACCESS_KEY BACKEND_UID BACKEND_GID BACKUP_DIR +BACKUP_STATUS_DIR BACKUP_RESTORE_DIR BACKUP_OSS_URI BACKUP_PASSPHRASE @@ -92,15 +93,23 @@ RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要 ./scripts/backup-online.sh prune ``` +后台“数据备份”页面只读取 `${BACKUP_STATUS_DIR}/status.json`;该目录以只读方式挂载给 backend,不包含备份载荷。首次部署后先生成状态摘要: + +```bash +./scripts/backup-status.sh write +``` + 推荐 cron(以部署用户运行,并把日志写到受限目录): ```cron -* * * * * cd /srv/hfb_sys && ./scripts/archive-binlog.sh archive >> /data/backups/archive-binlog.log 2>&1 -30 4 * * * cd /srv/hfb_sys && ./scripts/backup-online.sh full >> /data/backups/full-backup.log 2>&1 +# 每分钟读取后台定时配置,按需归档 binlog 和执行全量热备。 +* * * * * cd /srv/hfb_sys && ./scripts/backup-scheduler.sh >> /data/backups/backup-scheduler.log 2>&1 15 5 * * * cd /srv/hfb_sys && ./scripts/backup-online.sh prune >> /data/backups/prune.log 2>&1 10 5 * * 0 cd /srv/hfb_sys && ./scripts/archive-binlog.sh verify >> /data/backups/archive-verify.log 2>&1 ``` +全量备份时间、是否启用定时任务和 binlog 间隔在后台“数据备份”页面配置;宿主机 cron 必须保留每分钟的调度器入口。不要将 Docker socket、备份目录或恢复操作暴露给 backend。 + OSS 生命周期建议:`mysql/full/daily/` 30 天、`weekly/` 60 天、`monthly/` 365 天、`mysql/binlog/` 至少 45 天。时间点恢复窗口受最早全量备份和 binlog 保留期共同限制。 每周必须在隔离目录做一次恢复演练,绝不向生产容器回放: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 4553324..cfa5af2 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -87,6 +87,8 @@ services: TZ: Asia/Shanghai volumes: - ../backend/logs:/app/logs + # 仅挂载备份状态摘要,后端无法读取备份文件、Docker socket 或 OSS 凭证文件。 + - ${BACKUP_STATUS_DIR:-/data/backups/status}:/var/run/hfb-backup-status:ro deploy: resources: limits: diff --git a/frontend/src/features/admin/api/backup.ts b/frontend/src/features/admin/api/backup.ts new file mode 100644 index 0000000..96aa19d --- /dev/null +++ b/frontend/src/features/admin/api/backup.ts @@ -0,0 +1,32 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' + +export interface BackupSchedule { + enabled: boolean + full_hour: number + full_minute: number + binlog_interval_minutes: number +} + +export interface BackupStatus { + generated_at: string + health: 'healthy' | 'warning' + last_full: { local_complete: string; remote_complete: string } + last_binlog: { file: string; remote_dir: string } + last_job: { type: string; status: string; at: string; message: string } +} + +export async function fetchBackupStatus() { + const { data } = await apiClient.get>('/admin/backups/status') + return data.data +} + +export async function fetchBackupSchedule() { + const { data } = await apiClient.get>('/admin/backups/schedule') + return data.data +} + +export async function updateBackupSchedule(payload: BackupSchedule) { + const { data } = await apiClient.put>('/admin/backups/schedule', payload) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminBackupView.vue b/frontend/src/features/admin/views/AdminBackupView.vue new file mode 100644 index 0000000..13f0d9a --- /dev/null +++ b/frontend/src/features/admin/views/AdminBackupView.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index d659b8d..08e7a4a 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -219,6 +219,12 @@ const allNavGroups: NavGroup[] = [ icon: Operation, permission: 'system_config:view', }, + { + label: '数据备份', + to: adminPath('backups'), + icon: DocumentChecked, + permission: 'backup:view', + }, ], }, { diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 99da303..0d3b7d1 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -162,6 +162,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminSystemConfigsView.vue'), meta: adminMeta, }, + { + path: adminPath('backups'), + name: 'admin-backups', + component: () => import('@/features/admin/views/AdminBackupView.vue'), + meta: adminMeta, + }, { path: adminPath('payment-configs'), name: 'admin-payment-configs', diff --git a/scripts/backup-scheduler.sh b/scripts/backup-scheduler.sh new file mode 100755 index 0000000..bdb168d --- /dev/null +++ b/scripts/backup-scheduler.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 由宿主机 cron 每分钟调用。调度配置来自后台 system_configs,实际执行仍在宿主机, +# Web 后端不接触 Docker socket、备份载荷或客户端加密密码。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +config_value() { + local key="$1" fallback="$2" value + value="$(backup_mysql_root_query "SELECT value FROM system_configs WHERE \`key\` = '${key}' LIMIT 1;" 2>/dev/null || true)" + value="${value//$'\n'/}" + printf '%s' "${value:-${fallback}}" +} + +write_job_state() { + local status_dir="$1" type="$2" status="$3" message="$4" + mkdir -p "${status_dir}" + { + printf 'job_type=%q\n' "${type}" + printf 'job_status=%q\n' "${status}" + printf 'job_at=%q\n' "$(date -Iseconds)" + printf 'job_message=%q\n' "${message}" + } > "${status_dir}/last-job.env" + chmod 600 "${status_dir}/last-job.env" +} + +run_job() { + local status_dir="$1" type="$2" script="$3" + shift 3 + if "${script}" "$@"; then + write_job_state "${status_dir}" "${type}" success '执行成功' + "${SCRIPT_DIR}/backup-status.sh" write || true + return + fi + local code=$? + write_job_state "${status_dir}" "${type}" failed "执行失败(退出码 ${code})" + "${SCRIPT_DIR}/backup-status.sh" write || true + return "${code}" +} + +main() { + backup_require_cmd docker + backup_require_cmd flock + local backup_dir status_dir enabled hour minute interval now_hour now_minute + backup_dir="$(backup_require_env BACKUP_DIR)" + status_dir="${BACKUP_STATUS_DIR:-${backup_dir}/status}" + backup_validate_dir "${status_dir}" + backup_lock_or_exit "${backup_dir}/.scheduler.lock" + + enabled="$(config_value 'backup.schedule_enabled' 'true')" + hour="$(config_value 'backup.full_hour' '4')" + minute="$(config_value 'backup.full_minute' '30')" + interval="$(config_value 'backup.binlog_interval_minutes' '1')" + [[ "${enabled}" == true || "${enabled}" == false ]] || { backup_error 'backup.schedule_enabled 必须为 true 或 false'; exit 1; } + [[ "${hour}" =~ ^[0-9]+$ && "${hour}" -le 23 && "${minute}" =~ ^[0-9]+$ && "${minute}" -le 59 ]] || { + backup_error '备份全量时间配置无效' + exit 1 + } + [[ "${interval}" =~ ^[0-9]+$ && "${interval}" -ge 1 && "${interval}" -le 5 ]] || { + backup_error 'binlog 归档间隔必须为 1-5 分钟' + exit 1 + } + if [[ "${enabled}" == false ]]; then + "${SCRIPT_DIR}/backup-status.sh" write || true + exit 0 + fi + + now_hour="$((10#$(date +%H)))" + now_minute="$((10#$(date +%M)))" + if ((now_minute % interval == 0)); then + run_job "${status_dir}" binlog "${SCRIPT_DIR}/archive-binlog.sh" archive + fi + if ((now_hour == hour && now_minute == minute)); then + run_job "${status_dir}" full "${SCRIPT_DIR}/backup-online.sh" full + fi + "${SCRIPT_DIR}/backup-status.sh" write || true +} + +main "$@" diff --git a/scripts/backup-status.sh b/scripts/backup-status.sh new file mode 100755 index 0000000..24e6917 --- /dev/null +++ b/scripts/backup-status.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 生成供后台只读展示的备份状态摘要。内容不包含密钥、备份载荷或下载地址。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/ }" + value="${value//$'\r'/ }" + printf '%s' "${value}" +} + +config_value() { + local key="$1" fallback="$2" value + value="$(backup_mysql_root_query "SELECT value FROM system_configs WHERE \`key\` = '${key}' LIMIT 1;" 2>/dev/null || true)" + value="${value//$'\n'/}" + printf '%s' "${value:-${fallback}}" +} + +write_status() { + backup_require_cmd docker + backup_require_cmd ossutil + + local backup_dir oss_uri status_dir status_file temp_file enabled hour minute interval + local local_complete remote_complete binlog_state binlog_file binlog_remote job_type job_status job_at job_message health + backup_dir="$(backup_require_env BACKUP_DIR)" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + status_dir="${BACKUP_STATUS_DIR:-${backup_dir}/status}" + backup_validate_dir "${status_dir}" + mkdir -p "${status_dir}" + chmod 700 "${status_dir}" + status_file="${status_dir}/status.json" + temp_file="${status_dir}/.status-$$.json" + + enabled="$(config_value 'backup.schedule_enabled' 'true')" + hour="$(config_value 'backup.full_hour' '4')" + minute="$(config_value 'backup.full_minute' '30')" + interval="$(config_value 'backup.binlog_interval_minutes' '1')" + local_complete="$(find "${backup_dir}/full/daily" -name complete.env -type f -print 2>/dev/null | sort | tail -1 || true)" + remote_complete="$(ossutil ls "${oss_uri}/mysql/full/daily/" -r 2>/dev/null | awk '$NF ~ /\/complete\.env$/ { latest = $NF } END { print latest }')" + binlog_state="$(find "${backup_dir}/binlog-state" -name last-archived.env -type f -print 2>/dev/null | sort | tail -1 || true)" + binlog_file='' + binlog_remote='' + if [[ -n "${binlog_state}" ]]; then + binlog_file="$(awk -F= '$1 == "filename" { print substr($0, index($0, "=") + 1); exit }' "${binlog_state}")" + binlog_remote="$(awk -F= '$1 == "remote_dir" { print substr($0, index($0, "=") + 1); exit }' "${binlog_state}")" + fi + + job_type='' + job_status='' + job_at='' + job_message='' + if [[ -f "${status_dir}/last-job.env" ]]; then + # shellcheck disable=SC1090 + source "${status_dir}/last-job.env" + fi + if [[ -n "${local_complete}" && -n "${remote_complete}" && -n "${binlog_file}" ]]; then + health='healthy' + else + health='warning' + fi + + cat > "${temp_file}" <&2 + exit 1 + ;; +esac diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 51dd780..249eaa9 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -146,7 +146,7 @@ validate_env() { exit 1 fi - local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid backup_dir backup_restore_dir backup_oss_uri backup_user + local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid backup_dir backup_status_dir backup_restore_dir backup_oss_uri backup_user app_env="$(require_env APP_ENV)" caddy_domain="$(require_env CADDY_DOMAIN)" caddy_email="$(require_env CADDY_EMAIL)" @@ -162,6 +162,7 @@ validate_env() { require_env STORAGE_ACCESS_KEY_ID >/dev/null require_env STORAGE_SECRET_ACCESS_KEY >/dev/null backup_dir="$(require_env BACKUP_DIR)" + backup_status_dir="$(require_env BACKUP_STATUS_DIR)" backup_restore_dir="$(require_env BACKUP_RESTORE_DIR)" backup_oss_uri="$(require_env BACKUP_OSS_URI)" require_env BACKUP_PASSPHRASE >/dev/null @@ -216,6 +217,10 @@ validate_env() { log_error "BACKUP_DIR 必须是 /data 之下的具体绝对路径,当前值:${backup_dir}" exit 1 fi + if [[ "${backup_status_dir}" != "${backup_dir}/"* ]]; then + log_error "BACKUP_STATUS_DIR 必须位于 BACKUP_DIR 之下,当前值:${backup_status_dir}" + exit 1 + fi if [[ "${backup_restore_dir}" != /data/* || "${backup_restore_dir}" == "/data" || "${backup_restore_dir}" == "${backup_dir}" ]]; then log_error "BACKUP_RESTORE_DIR 必须是与 BACKUP_DIR 不同的 /data 子目录,当前值:${backup_restore_dir}" exit 1 @@ -403,6 +408,24 @@ prepare_log_dir() { chmod 700 "${BACKEND_LOG_DIR}" } +prepare_backup_status_dir() { + local backend_uid backend_gid status_dir owner_mismatch + backend_uid="$(require_env BACKEND_UID)" + backend_gid="$(require_env BACKEND_GID)" + status_dir="$(require_env BACKUP_STATUS_DIR)" + mkdir -p "${status_dir}" + owner_mismatch="$(find "${status_dir}" \( ! -uid "${backend_uid}" -o ! -gid "${backend_gid}" \) -print -quit)" + if [[ -n "${owner_mismatch}" ]]; then + if [[ "${EUID}" -ne 0 ]]; then + log_error "备份状态目录存在属主不匹配的文件:${owner_mismatch}" + log_error "请先执行:sudo chown -R ${backend_uid}:${backend_gid} ${status_dir}" + exit 1 + fi + chown -R "${backend_uid}:${backend_gid}" "${status_dir}" + fi + chmod 700 "${status_dir}" +} + wait_service_healthy() { local service="$1" local label="$2" @@ -557,6 +580,7 @@ main() { validate_env export_caddy_env prepare_log_dir + prepare_backup_status_dir # 选号网需在 build caddy 之前准备 show-dist 与 conf.d prepare_show_site From a0bf47f2ed792a315eed9d6e1ac0153b07d911a8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 23:57:19 +0800 Subject: [PATCH 10/70] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=A9=BA=E9=97=B2binlo?= =?UTF-8?q?g=E8=BD=AE=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/README.md | 4 +-- scripts/archive-binlog.sh | 55 ++++++++++++++++++++++++++++++++++----- scripts/backup-online.sh | 2 +- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index bc3e37d..addfa99 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -72,7 +72,7 @@ RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要 ### 数据库在线备份与时间点恢复 -生产 MySQL 开启了 `ROW` 格式 binlog 和崩溃安全刷盘。备份期间业务持续读写:每日用 XtraBackup 做物理全量备份,每分钟轮转并归档已关闭的 binlog。备份对象先做客户端加密,再由 Bucket 的服务器端加密保护;远端仅在 payload、checksum 和 manifest 都上传成功后写入 `complete.env`。 +生产 MySQL 开启了 `ROW` 格式 binlog 和崩溃安全刷盘。备份期间业务持续读写:每日用 XtraBackup 做物理全量备份,调度器每分钟检查 binlog 位点,仅在有新增已提交事务时轮转并归档。备份对象先做客户端加密,再由 Bucket 的服务器端加密保护;远端仅在 payload、checksum 和 manifest 都上传成功后写入 `complete.env`。 部署脚本会创建或更新 `BACKUP_MYSQL_USER`,该用户只有 XtraBackup 所需的全局备份权限和业务库只读权限,不开放远程 root。 @@ -84,7 +84,7 @@ RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要 # 每日全量热备,本地留存并上传 OSS 备份桶 ./scripts/backup-online.sh full -# binlog 分钟级归档,配合全量实现最近保留窗口内的时间点恢复 +# 检查并归档有新增事务的关闭 binlog,配合全量实现时间点恢复 ./scripts/archive-binlog.sh archive # 校验本地状态对应的远端完整对象 / 清理过期本地备份 diff --git a/scripts/archive-binlog.sh b/scripts/archive-binlog.sh index 80d7c1d..29bbe98 100755 --- a/scripts/archive-binlog.sh +++ b/scripts/archive-binlog.sh @@ -2,8 +2,8 @@ set -euo pipefail umask 077 -# 分钟级 binlog 归档。每次轮转后只归档已关闭文件;每个对象以 -# server_uuid、原始文件名和原始 sha256 命名,避免实例重建后的编号碰撞。 +# 分钟级 binlog 归档。每分钟检查活动 binlog 的 Position,仅在有新增已提交事务时 +# 轮转;每个对象以 server_uuid、原始文件名和原始 sha256 命名,避免实例重建后的编号碰撞。 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=backup-common.sh @@ -31,6 +31,29 @@ closed_binlogs() { 2>/dev/null | awk 'NR > 1 { print previous } { previous = $1 } END { }' } +master_status() { + local mysql_cid="$1" + docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "SHOW MASTER STATUS;"' \ + 2>/dev/null | awk 'NR == 1 { print $1 "\t" $2 }' +} + +state_active_binlog() { + local state_dir="$1" key="$2" value + [[ -f "${state_dir}/active-binlog.env" ]] || return + value="$(awk -F= -v key="${key}" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "${state_dir}/active-binlog.env")" + printf '%s' "${value}" +} + +write_active_binlog_state() { + local state_dir="$1" filename="$2" position="$3" + { + printf 'filename=%s\n' "${filename}" + printf 'position=%s\n' "${position}" + printf 'observed_at=%s\n' "$(date -Iseconds)" + } > "${state_dir}/active-binlog.env" +} + state_last_index() { local state_dir="$1" local value @@ -103,7 +126,12 @@ archive() { backup_require_cmd ossutil backup_require_cmd openssl require_archive_config - local backup_dir oss_uri mysql_cid server_uuid state_dir first_binlog last_index first_index expected_index filename index + local force="${1:-}" backup_dir oss_uri mysql_cid server_uuid state_dir first_binlog last_index first_index expected_index filename index + local current_status current_file current_pos previous_file previous_pos final_status final_file final_pos + if [[ -n "${force}" && "${force}" != '--force' ]]; then + printf '用法: %s archive [--force]\n' "$0" >&2 + exit 1 + fi backup_dir="$(backup_require_env BACKUP_DIR)" backup_validate_dir "${backup_dir}" oss_uri="$(backup_require_env BACKUP_OSS_URI)" @@ -115,8 +143,19 @@ archive() { work_dir="${backup_dir}/.tmp-binlog-$$" mkdir -p "${state_dir}" "${work_dir}" - docker exec "${mysql_cid}" sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -e "FLUSH BINARY LOGS;"' >/dev/null + current_status="$(master_status "${mysql_cid}")" + IFS=$'\t' read -r current_file current_pos <<< "${current_status}" + [[ -n "${current_file}" && "${current_pos}" =~ ^[0-9]+$ ]] || { backup_error '无法读取当前 binlog 位点'; exit 1; } + backup_binlog_index "${current_file}" >/dev/null + previous_file="$(state_active_binlog "${state_dir}" filename || true)" + previous_pos="$(state_active_binlog "${state_dir}" position || true)" + + if [[ "${force}" == '--force' || -z "${previous_file}" || -z "${previous_pos}" || "${current_file}" != "${previous_file}" || "${current_pos}" != "${previous_pos}" ]]; then + docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -e "FLUSH BINARY LOGS;"' >/dev/null + else + backup_log "活动 binlog 无新增已提交事务,跳过轮转" + fi first_binlog="$(docker exec "${mysql_cid}" sh -c \ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "SHOW BINARY LOGS;"' | head -1 | awk '{print $1}')" [[ -n "${first_binlog}" ]] || { backup_error '无法读取 binlog 列表'; exit 1; } @@ -141,6 +180,10 @@ archive() { archive_one "${mysql_cid}" "${state_dir}" "${server_uuid}" "${oss_uri}" "${filename}" expected_index="${index}" done < <(closed_binlogs "${mysql_cid}") + final_status="$(master_status "${mysql_cid}")" + IFS=$'\t' read -r final_file final_pos <<< "${final_status}" + [[ -n "${final_file}" && "${final_pos}" =~ ^[0-9]+$ ]] || { backup_error '无法读取归档后的 binlog 位点'; exit 1; } + write_active_binlog_state "${state_dir}" "${final_file}" "${final_pos}" backup_log "binlog 归档完成,实例 ${server_uuid}" } @@ -165,7 +208,7 @@ verify() { } case "${1:-archive}" in - archive) archive ;; + archive) archive "${2:-}" ;; verify) verify ;; *) printf '用法: %s {archive|verify}\n' "$0" >&2 diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh index 710ff15..4672710 100755 --- a/scripts/backup-online.sh +++ b/scripts/backup-online.sh @@ -123,7 +123,7 @@ EOF # 关闭包含 XtraBackup 位点的当前 binlog 并归档。全量备份只有在该文件 # 已有远端 complete 标识后才会被标记完成。 - BACKUP_LOCK_WAIT_SECONDS=120 "${SCRIPT_DIR}/archive-binlog.sh" archive + BACKUP_LOCK_WAIT_SECONDS=120 "${SCRIPT_DIR}/archive-binlog.sh" archive --force if ! find "${backup_dir}/binlog-state/${server_uuid}" -maxdepth 1 -type f \ -name "${binlog_file}.*.state" -print -quit | grep -q .; then backup_error "XtraBackup 位点 ${binlog_file}:${binlog_pos} 尚未完成 binlog 归档" From 79eebdc8e512fe545021d7a9d6e98dbff154995d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 17 Aug 2026 00:08:09 +0800 Subject: [PATCH 11/70] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=81=A5=E5=BA=B7=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/backup-status.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/backup-status.sh b/scripts/backup-status.sh index 24e6917..8b6043a 100755 --- a/scripts/backup-status.sh +++ b/scripts/backup-status.sh @@ -61,7 +61,9 @@ write_status() { # shellcheck disable=SC1090 source "${status_dir}/last-job.env" fi - if [[ -n "${local_complete}" && -n "${remote_complete}" && -n "${binlog_file}" ]]; then + if [[ "${job_status}" == 'failed' ]]; then + health='warning' + elif [[ -n "${local_complete}" && -n "${remote_complete}" && -n "${binlog_file}" ]]; then health='healthy' else health='warning' From 2265c48a5306d5079671866ddc6ce6c2565ca40a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 17 Aug 2026 00:17:42 +0800 Subject: [PATCH 12/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=80=80=E5=87=BA=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/backup-scheduler.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/backup-scheduler.sh b/scripts/backup-scheduler.sh index bdb168d..a803761 100755 --- a/scripts/backup-scheduler.sh +++ b/scripts/backup-scheduler.sh @@ -35,11 +35,13 @@ run_job() { write_job_state "${status_dir}" "${type}" success '执行成功' "${SCRIPT_DIR}/backup-status.sh" write || true return + else + # 必须在失败分支中立即读取退出码;if 语句本身在无 else 时会返回 0。 + local code=$? + write_job_state "${status_dir}" "${type}" failed "执行失败(退出码 ${code})" + "${SCRIPT_DIR}/backup-status.sh" write || true + return "${code}" fi - local code=$? - write_job_state "${status_dir}" "${type}" failed "执行失败(退出码 ${code})" - "${SCRIPT_DIR}/backup-status.sh" write || true - return "${code}" } main() { From 76528076a787fec77521af1e4ed98ea67cd9bb83 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 17 Aug 2026 00:19:39 +0800 Subject: [PATCH 13/70] =?UTF-8?q?=E8=A1=A5=E5=85=85binlog=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/archive-binlog.sh | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/scripts/archive-binlog.sh b/scripts/archive-binlog.sh index 29bbe98..b9e640a 100755 --- a/scripts/archive-binlog.sh +++ b/scripts/archive-binlog.sh @@ -24,18 +24,21 @@ require_archive_config() { backup_require_env BACKUP_PASSPHRASE >/dev/null } -closed_binlogs() { - local mysql_cid="$1" - docker exec "${mysql_cid}" sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "SHOW BINARY LOGS;"' \ - 2>/dev/null | awk 'NR > 1 { print previous } { previous = $1 } END { }' +mysql_binlog_query() { + local mysql_cid="$1" query="$2" output code + output="$(docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "$1"' \ + sh "${query}" 2>&1)" || { + code=$? + backup_error "MySQL binlog 查询失败(${query}):${output}" + return "${code}" + } + printf '%s\n' "${output}" } master_status() { local mysql_cid="$1" - docker exec "${mysql_cid}" sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "SHOW MASTER STATUS;"' \ - 2>/dev/null | awk 'NR == 1 { print $1 "\t" $2 }' + mysql_binlog_query "${mysql_cid}" 'SHOW MASTER STATUS;' | awk 'NR == 1 { print $1 "\t" $2 }' } state_active_binlog() { @@ -127,7 +130,7 @@ archive() { backup_require_cmd openssl require_archive_config local force="${1:-}" backup_dir oss_uri mysql_cid server_uuid state_dir first_binlog last_index first_index expected_index filename index - local current_status current_file current_pos previous_file previous_pos final_status final_file final_pos + local current_status current_file current_pos previous_file previous_pos final_status final_file final_pos binlog_list closed_binlog_list if [[ -n "${force}" && "${force}" != '--force' ]]; then printf '用法: %s archive [--force]\n' "$0" >&2 exit 1 @@ -156,8 +159,8 @@ archive() { else backup_log "活动 binlog 无新增已提交事务,跳过轮转" fi - first_binlog="$(docker exec "${mysql_cid}" sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "SHOW BINARY LOGS;"' | head -1 | awk '{print $1}')" + binlog_list="$(mysql_binlog_query "${mysql_cid}" 'SHOW BINARY LOGS;')" + first_binlog="$(awk 'NR == 1 { print $1; exit }' <<< "${binlog_list}")" [[ -n "${first_binlog}" ]] || { backup_error '无法读取 binlog 列表'; exit 1; } last_index="$(state_last_index "${state_dir}" || true)" first_index="$(backup_binlog_index "${first_binlog}")" @@ -166,6 +169,7 @@ archive() { exit 1 fi + closed_binlog_list="$(awk 'NR > 1 { print previous } { previous = $1 }' <<< "${binlog_list}")" expected_index="${last_index}" while IFS= read -r filename; do [[ -n "${filename}" ]] || continue @@ -179,7 +183,7 @@ archive() { fi archive_one "${mysql_cid}" "${state_dir}" "${server_uuid}" "${oss_uri}" "${filename}" expected_index="${index}" - done < <(closed_binlogs "${mysql_cid}") + done <<< "${closed_binlog_list}" final_status="$(master_status "${mysql_cid}")" IFS=$'\t' read -r final_file final_pos <<< "${final_status}" [[ -n "${final_file}" && "${final_pos}" =~ ^[0-9]+$ ]] || { backup_error '无法读取归档后的 binlog 位点'; exit 1; } From 50b458e5c2cb816e4308e35a0c83cbd45ddbb60b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 17 Aug 2026 00:23:33 +0800 Subject: [PATCH 14/70] =?UTF-8?q?=E9=80=82=E9=85=8DMySQL8.4=E4=BD=8D?= =?UTF-8?q?=E7=82=B9=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/archive-binlog.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/archive-binlog.sh b/scripts/archive-binlog.sh index b9e640a..3017349 100755 --- a/scripts/archive-binlog.sh +++ b/scripts/archive-binlog.sh @@ -38,7 +38,7 @@ mysql_binlog_query() { master_status() { local mysql_cid="$1" - mysql_binlog_query "${mysql_cid}" 'SHOW MASTER STATUS;' | awk 'NR == 1 { print $1 "\t" $2 }' + mysql_binlog_query "${mysql_cid}" 'SHOW BINARY LOG STATUS;' | awk 'NR == 1 { print $1 "\t" $2 }' } state_active_binlog() { From 79ced2d121519017aca1ec141499703698377825 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 17 Aug 2026 10:36:15 +0800 Subject: [PATCH 15/70] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/admin/views/AdminBackupView.vue | 219 +++++++++++++++--- 1 file changed, 182 insertions(+), 37 deletions(-) diff --git a/frontend/src/features/admin/views/AdminBackupView.vue b/frontend/src/features/admin/views/AdminBackupView.vue index 13f0d9a..f97e7ac 100644 --- a/frontend/src/features/admin/views/AdminBackupView.vue +++ b/frontend/src/features/admin/views/AdminBackupView.vue @@ -17,8 +17,15 @@ const loading = ref(false) const saving = ref(false) const status = ref(null) const statusError = ref('') -const canUpdateSchedule = computed(() => adminSession.isSuperAdmin || adminSession.hasPermission('backup:schedule:update')) -const schedule = reactive({ enabled: true, full_hour: 4, full_minute: 30, binlog_interval_minutes: 1 }) +const canUpdateSchedule = computed( + () => adminSession.isSuperAdmin || adminSession.hasPermission('backup:schedule:update') +) +const schedule = reactive({ + enabled: true, + full_hour: 4, + full_minute: 30, + binlog_interval_minutes: 1, +}) const healthLabel = computed(() => (status.value?.health === 'healthy' ? '链路正常' : '需要检查')) const healthType = computed(() => (status.value?.health === 'healthy' ? 'success' : 'warning')) @@ -27,7 +34,10 @@ async function load() { loading.value = true statusError.value = '' try { - const [nextStatus, nextSchedule] = await Promise.all([fetchBackupStatus(), fetchBackupSchedule()]) + const [nextStatus, nextSchedule] = await Promise.all([ + fetchBackupStatus(), + fetchBackupSchedule(), + ]) status.value = nextStatus Object.assign(schedule, nextSchedule) } catch (error) { @@ -64,7 +74,7 @@ onMounted(load)
- + {{ healthLabel }}
@@ -77,7 +87,7 @@ onMounted(load)
- +
本地完成标识
@@ -87,7 +97,7 @@ onMounted(load)
- +
Binlog 文件
@@ -101,41 +111,176 @@ onMounted(load) - - - - -
- - : - -
-
- - - - - - - - - 保存定时配置 +
+ + + + +
+ + : + +
+
+ + + + + + + + +
+
+ 保存定时配置 +
From 0bc6cf9f468eb2c9decdefb91145fc5262e1a817 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 17 Aug 2026 20:30:15 +0800 Subject: [PATCH 16/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E5=8C=BA=E6=9C=8D=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/listing/external_upload.go | 5 +++-- .../internal/modules/listing/service_test.go | 22 +++++++++++++++++++ ...0051_fix_external_wechat_server_region.sql | 21 ++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/000051_fix_external_wechat_server_region.sql diff --git a/backend/internal/modules/listing/external_upload.go b/backend/internal/modules/listing/external_upload.go index a812db0..3c467cd 100644 --- a/backend/internal/modules/listing/external_upload.go +++ b/backend/internal/modules/listing/external_upload.go @@ -382,10 +382,11 @@ func buildExternalListingTitle(rank string, insurance string, hafCoinM float64, func serverRegionFromLoginMethod(value string) string { value = strings.TrimSpace(value) + normalized := strings.ToLower(value) switch { - case strings.Contains(value, "微信"): + case strings.Contains(value, "微信"), strings.HasPrefix(normalized, "vx"), strings.HasPrefix(normalized, "wx"): return "微信" - case strings.Contains(strings.ToLower(value), "steam"): + case strings.Contains(normalized, "steam"): return "Steam" default: return "QQ" diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 19e8b01..c623461 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -566,6 +566,28 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) { } } +func TestServerRegionFromLoginMethod(t *testing.T) { + tests := []struct { + name string + loginMethod string + want string + }{ + {name: "QQ账号密码", loginMethod: "QQ账号密码", want: "QQ"}, + {name: "微信扫码", loginMethod: "微信扫码", want: "微信"}, + {name: "VX扫码", loginMethod: "VX扫码", want: "微信"}, + {name: "小写WX扫码", loginMethod: "wx扫码", want: "微信"}, + {name: "Steam", loginMethod: "Steam令牌", want: "Steam"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := serverRegionFromLoginMethod(tt.loginMethod); got != tt.want { + t.Fatalf("serverRegionFromLoginMethod(%q) = %q, want %q", tt.loginMethod, got, tt.want) + } + }) + } +} + func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) { req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{ LoginMethod: "QQ账号密码", diff --git a/backend/migrations/000051_fix_external_wechat_server_region.sql b/backend/migrations/000051_fix_external_wechat_server_region.sql new file mode 100644 index 0000000..4745e27 --- /dev/null +++ b/backend/migrations/000051_fix_external_wechat_server_region.sql @@ -0,0 +1,21 @@ +-- +goose Up + +-- 修正历史外部上传中以 VX/WX 表示微信扫码、但被默认归类为 QQ 的账号。 +UPDATE game_accounts AS a +JOIN rental_listings AS l ON l.account_id = a.id +SET a.server_region = '微信' +WHERE a.server_region = 'QQ' + AND EXISTS ( + SELECT 1 + FROM listing_uploads AS lu + WHERE lu.listing_id = l.id + ) + AND ( + a.login_platform LIKE '%微信%' + OR LOWER(TRIM(a.login_platform)) LIKE 'vx%' + OR LOWER(TRIM(a.login_platform)) LIKE 'wx%' + ); + +-- +goose Down + +-- 此数据修复不可逆,避免将已修正的微信账号错误回退为 QQ。 From c91423a19bd41540187f72e9c22b7186134edcd7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 18 Aug 2026 20:02:21 +0800 Subject: [PATCH 17/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=86=BB=E7=BB=93?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=BC=BA=E5=88=B6=E4=B8=8B=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/middleware/auth.go | 15 +++- backend/internal/middleware/auth_test.go | 36 ++++++++++ backend/internal/model/user.go | 1 + .../internal/modules/adminuser/repository.go | 33 ++++++--- .../modules/adminuser/repository_test.go | 32 +++++++++ backend/internal/modules/adminuser/service.go | 7 ++ backend/internal/modules/auth/handler.go | 6 +- backend/internal/modules/auth/repository.go | 20 ++++++ backend/internal/modules/auth/service.go | 25 +++++-- .../modules/auth/token_version_test.go | 71 +++++++++++++++++++ backend/internal/modules/chathub/hub.go | 16 +++++ backend/internal/modules/chathub/hub_test.go | 25 +++++++ backend/internal/router/router.go | 10 ++- .../migrations/000052_user_token_version.sql | 9 +++ .../features/chats/composables/useChatSSE.ts | 8 ++- frontend/src/shared/api/client.ts | 2 +- 16 files changed, 297 insertions(+), 19 deletions(-) create mode 100644 backend/internal/middleware/auth_test.go create mode 100644 backend/internal/modules/auth/token_version_test.go create mode 100644 backend/internal/modules/chathub/hub_test.go create mode 100644 backend/migrations/000052_user_token_version.sql diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 1ffa72f..9549ed8 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -34,6 +34,8 @@ type AdminTokenContext struct { type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error) +type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error + func extractBearerToken(c *gin.Context) string { header := c.GetHeader("Authorization") tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) @@ -50,7 +52,7 @@ func extractToken(c *gin.Context) string { return c.Query("token") } -func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc { +func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.HandlerFunc { return func(c *gin.Context) { tokenText := extractToken(c) if tokenText == "" { @@ -65,6 +67,17 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc { c.Abort() return } + if validate != nil { + if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil { + if errors.Is(err, auth.ErrDependencyUnavailable) { + response.ServiceUnavailable(c, "用户认证服务暂时不可用") + } else { + response.Unauthorized(c, "登录状态已失效,请重新登录") + } + c.Abort() + return + } + } c.Set(ContextUserID, claims.UserID) c.Set(ContextPhone, claims.Phone) diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go new file mode 100644 index 0000000..e236f06 --- /dev/null +++ b/backend/internal/middleware/auth_test.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "hfb_sys/backend/internal/modules/auth" + + "github.com/gin-gonic/gin" +) + +func TestAuthRejectsRevokedUserToken(t *testing.T) { + gin.SetMode(gin.TestMode) + manager := auth.NewJWTManager("test-secret") + pair, err := manager.GenerateSubjectPairWithVersion(7, "13900000007", "user", 0) + if err != nil { + t.Fatalf("生成令牌失败:%v", err) + } + + engine := gin.New() + engine.GET("/protected", Auth(manager, func(_ context.Context, _ uint64, _ int64) error { + return auth.ErrTokenVersionMismatch + }), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + request.Header.Set("Authorization", "Bearer "+pair.AccessToken) + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized { + t.Fatalf("响应状态 = %d, want %d", response.Code, http.StatusUnauthorized) + } +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 96d9407..6610291 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -15,6 +15,7 @@ type User struct { RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"` RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"` Status string `gorm:"size:32;not null;default:'active'" json:"status"` + TokenVersion int64 `gorm:"not null;default:0" json:"-"` LastLoginAt *time.Time `json:"last_login_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 5ced310..51970bd 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -19,8 +19,9 @@ import ( ) type Repository struct { - db *gorm.DB - encryptor crypto.Encryptor + db *gorm.DB + encryptor crypto.Encryptor + statusChangeNotifier func(userID uint64) } type AuditMeta = auditlog.Meta @@ -33,6 +34,11 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository { return &Repository{db: db, encryptor: encryptor} } +// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。 +func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) { + r.statusChangeNotifier = notifier +} + func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) { growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx)) if err != nil { @@ -207,6 +213,7 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use } func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) { + statusChanged := false err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var user model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { @@ -214,23 +221,33 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui } beforeStatus := user.Status beforeRisk := user.RiskStatus + beforeTokenVersion := user.TokenVersion user.Status = status user.RiskStatus = riskStatus + if beforeStatus != status { + user.TokenVersion++ + statusChanged = true + } if err := tx.Save(&user).Error; err != nil { return err } return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{ - "user_id": user.ID, - "reason": reason, - "before_status": beforeStatus, - "after_status": status, - "before_risk_status": beforeRisk, - "after_risk_status": riskStatus, + "user_id": user.ID, + "reason": reason, + "before_status": beforeStatus, + "after_status": status, + "before_risk_status": beforeRisk, + "after_risk_status": riskStatus, + "before_token_version": beforeTokenVersion, + "after_token_version": user.TokenVersion, }) }) if err != nil { return nil, err } + if statusChanged && r.statusChangeNotifier != nil { + r.statusChangeNotifier(userID) + } return r.Find(ctx, userID) } diff --git a/backend/internal/modules/adminuser/repository_test.go b/backend/internal/modules/adminuser/repository_test.go index 5359fcf..0d4cadf 100644 --- a/backend/internal/modules/adminuser/repository_test.go +++ b/backend/internal/modules/adminuser/repository_test.go @@ -293,6 +293,38 @@ func TestAdjustWalletInsufficientBalance(t *testing.T) { } } +func TestFreezeAndUnfreezeBumpUserTokenVersion(t *testing.T) { + db := setupAdminUserTestDB(t) + user := model.User{Phone: "13900000006", Status: "active", TokenVersion: 0} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败:%v", err) + } + + repo := NewRepository(db) + got, err := repo.Freeze(t.Context(), 66, user.ID, FreezeRequest{Reason: "风险处置"}, AuditMeta{}) + if err != nil { + t.Fatalf("冻结用户失败:%v", err) + } + var saved model.User + if err := db.First(&saved, user.ID).Error; err != nil { + t.Fatalf("查询冻结用户失败:%v", err) + } + if got.Status != "frozen" || saved.TokenVersion != 1 { + t.Fatalf("冻结后状态/版本 = %s/%d, want frozen/1", got.Status, saved.TokenVersion) + } + + got, err = repo.Unfreeze(t.Context(), 66, user.ID, AuditMeta{}) + if err != nil { + t.Fatalf("解冻用户失败:%v", err) + } + if err := db.First(&saved, user.ID).Error; err != nil { + t.Fatalf("查询解冻用户失败:%v", err) + } + if got.Status != "active" || saved.TokenVersion != 2 { + t.Fatalf("解冻后状态/版本 = %s/%d, want active/2", got.Status, saved.TokenVersion) + } +} + func setupAdminUserTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index e7a4ed4..79718b8 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -37,6 +37,13 @@ func NewService(repo *Repository) *Service { return &Service{repo: repo} } +// SetSessionRevoker 设置用户会话撤销回调,例如关闭用户的实时连接。 +func (s *Service) SetSessionRevoker(revoker func(userID uint64)) { + if s.repo != nil { + s.repo.SetStatusChangeNotifier(revoker) + } +} + func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index 2c2b908..bdc7ed5 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -131,8 +131,12 @@ func (h *Handler) Refresh(c *gin.Context) { response.BadRequest(c, "refresh_token 不能为空") return } - tokens, err := h.service.RefreshToken(req.RefreshToken) + tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken) if err != nil { + if errors.Is(err, ErrDependencyUnavailable) { + response.ServiceUnavailable(c, "用户认证服务暂时不可用") + return + } response.Unauthorized(c, "刷新令牌无效或已过期") return } diff --git a/backend/internal/modules/auth/repository.go b/backend/internal/modules/auth/repository.go index f0d8721..2027f89 100644 --- a/backend/internal/modules/auth/repository.go +++ b/backend/internal/modules/auth/repository.go @@ -27,6 +27,26 @@ func (r *UserRepository) FindByID(ctx context.Context, id uint64) (*model.User, return &user, nil } +// FindActiveForToken 校验用户仍可用且令牌版本未被撤销。 +func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.User, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + var user model.User + if err := r.db.WithContext(ctx). + Select("id, phone, status, token_version"). + First(&user, id).Error; err != nil { + return nil, err + } + if user.Status != "active" { + return nil, ErrUserDisabled + } + if user.TokenVersion != tokenVersion { + return nil, ErrTokenVersionMismatch + } + return &user, nil +} + func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) { if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{ "nickname": nickname, diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index 0d362c0..6a37b4c 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -33,6 +33,7 @@ var ( ErrPasswordTooWeak = errors.New("password too weak") ErrLoginLocked = errors.New("login locked") ErrUserAlreadyExists = errors.New("user already exists") + ErrTokenVersionMismatch = errors.New("user token version mismatch") ) const ( @@ -180,7 +181,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) ( return LoginResult{}, ErrUserDisabled } - tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) + tokens, err := s.generateUserTokenPair(user) if err != nil { return LoginResult{}, err } @@ -189,12 +190,26 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) ( return LoginResult{User: user, Tokens: tokens}, nil } -func (s *Service) RefreshToken(refreshToken string) (TokenPair, error) { +func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) { + if s.jwt == nil || s.users == nil { + return TokenPair{}, ErrDependencyUnavailable + } claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user") if err != nil { return TokenPair{}, err } - return s.jwt.GeneratePair(claims.UserID, claims.Phone) + user, err := s.users.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion) + if err != nil { + return TokenPair{}, err + } + return s.generateUserTokenPair(user) +} + +func (s *Service) generateUserTokenPair(user *model.User) (TokenPair, error) { + if s.jwt == nil { + return TokenPair{}, ErrDependencyUnavailable + } + return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion) } func codeKey(phone string) string { @@ -264,7 +279,7 @@ func (s *Service) LoginWithPassword(ctx context.Context, phone, password, client _ = clearLoginFailure(ctx, s.redis, phone, clientIP) - tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) + tokens, err := s.generateUserTokenPair(user) if err != nil { return LoginResult{}, err } @@ -369,7 +384,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor return LoginResult{}, ErrUserDisabled } - tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) + tokens, err := s.generateUserTokenPair(user) if err != nil { return LoginResult{}, err } diff --git a/backend/internal/modules/auth/token_version_test.go b/backend/internal/modules/auth/token_version_test.go new file mode 100644 index 0000000..0dbfdcf --- /dev/null +++ b/backend/internal/modules/auth/token_version_test.go @@ -0,0 +1,71 @@ +package auth + +import ( + "errors" + "testing" + + "hfb_sys/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestUserTokenVersionRevokesAccessAndRefreshTokens(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("打开测试数据库失败:%v", err) + } + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("数据库迁移失败:%v", err) + } + + user := model.User{Phone: "13900000001", Status: "active", TokenVersion: 0} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败:%v", err) + } + repo := NewUserRepository(db) + manager := NewJWTManager("test-secret") + pair, err := manager.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion) + if err != nil { + t.Fatalf("生成令牌失败:%v", err) + } + + _, err = repo.FindActiveForToken(t.Context(), user.ID, 0) + if err != nil { + t.Fatalf("有效令牌校验失败:%v", err) + } + + if err := db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{ + "status": "frozen", + "token_version": 1, + }).Error; err != nil { + t.Fatalf("冻结用户失败:%v", err) + } + + _, err = repo.FindActiveForToken(t.Context(), user.ID, 0) + if !errors.Is(err, ErrUserDisabled) { + t.Fatalf("冻结用户校验错误 = %v, want %v", err, ErrUserDisabled) + } + + service := NewService(repo, nil, manager, nil, nil) + _, err = service.RefreshToken(t.Context(), pair.RefreshToken) + if !errors.Is(err, ErrUserDisabled) { + t.Fatalf("冻结用户刷新错误 = %v, want %v", err, ErrUserDisabled) + } + + // 解冻后版本仍然不同,冻结前签发的 token 不能恢复使用。 + if err := db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{ + "status": "active", + "token_version": 2, + }).Error; err != nil { + t.Fatalf("解冻用户失败:%v", err) + } + _, err = repo.FindActiveForToken(t.Context(), user.ID, 0) + if !errors.Is(err, ErrTokenVersionMismatch) { + t.Fatalf("旧令牌版本校验错误 = %v, want %v", err, ErrTokenVersionMismatch) + } + _, err = repo.FindActiveForToken(t.Context(), user.ID, 2) + if err != nil { + t.Fatalf("新令牌版本校验失败:%v", err) + } +} diff --git a/backend/internal/modules/chathub/hub.go b/backend/internal/modules/chathub/hub.go index 22f1d11..a99e53b 100644 --- a/backend/internal/modules/chathub/hub.go +++ b/backend/internal/modules/chathub/hub.go @@ -86,6 +86,22 @@ func (h *Hub) Unsubscribe(pType string, pID uint64, ch <-chan *ChatEvent) { h.mu.Unlock() } +// DisconnectUser 关闭指定用户当前进程内的全部实时连接。 +func (h *Hub) DisconnectUser(userID uint64) { + h.disconnect("user", userID) +} + +func (h *Hub) disconnect(pType string, pID uint64) { + key := principal{Type: pType, ID: pID} + h.mu.Lock() + clients := h.clients[key] + delete(h.clients, key) + for ch := range clients { + close(ch) + } + h.mu.Unlock() +} + // NotifyConversation 查询会话参与者,向所有在线参与者推送事件。 func (h *Hub) NotifyConversation(conversationID uint64, event *ChatEvent) { if h.db == nil { diff --git a/backend/internal/modules/chathub/hub_test.go b/backend/internal/modules/chathub/hub_test.go new file mode 100644 index 0000000..9d701e8 --- /dev/null +++ b/backend/internal/modules/chathub/hub_test.go @@ -0,0 +1,25 @@ +package chathub + +import "testing" + +func TestDisconnectUserClosesAllConnections(t *testing.T) { + hub := NewHub(nil) + first := hub.Subscribe("user", 7) + second := hub.Subscribe("user", 7) + hub.Subscribe("user", 8) + + hub.DisconnectUser(7) + for name, ch := range map[string]<-chan *ChatEvent{"first": first, "second": second} { + select { + case _, ok := <-ch: + if ok { + t.Fatalf("%s connection was not closed", name) + } + default: + t.Fatalf("%s connection did not close immediately", name) + } + } + if got := hub.OnlineCount(); got != 1 { + t.Fatalf("online connection count = %d, want 1", got) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 99c74a7..a6914a5 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -173,6 +173,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { var chatHub *chathub.Hub if deps.DB != nil { chatHub = chathub.NewHub(deps.DB) + adminUserService.SetSessionRevoker(chatHub.DisconnectUser) } var chatRepo *chat.Repository if deps.DB != nil { @@ -369,7 +370,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } announcementService := announcement.NewService(announcementRepo) announcementHandler := announcement.NewHandler(announcementService) - requireAuth := middleware.Auth(jwtManager) + validateUserToken := func(ctx context.Context, userID uint64, tokenVersion int64) error { + if userRepo == nil { + return auth.ErrDependencyUnavailable + } + _, err := userRepo.FindActiveForToken(ctx, userID, tokenVersion) + return err + } + requireAuth := middleware.Auth(jwtManager, validateUserToken) var validateAdminToken middleware.AdminTokenValidatorFunc if adminAuthRepo != nil { validateAdminToken = func(ctx context.Context, adminID uint64, tokenVersion int64) (middleware.AdminTokenContext, error) { diff --git a/backend/migrations/000052_user_token_version.sql b/backend/migrations/000052_user_token_version.sql new file mode 100644 index 0000000..3ce5e9e --- /dev/null +++ b/backend/migrations/000052_user_token_version.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE users + ADD COLUMN token_version BIGINT NOT NULL DEFAULT 0 COMMENT '用户会话版本,递增后旧令牌失效'; + +-- +goose Down + +ALTER TABLE users + DROP COLUMN token_version; diff --git a/frontend/src/features/chats/composables/useChatSSE.ts b/frontend/src/features/chats/composables/useChatSSE.ts index c8922b0..6c800ac 100644 --- a/frontend/src/features/chats/composables/useChatSSE.ts +++ b/frontend/src/features/chats/composables/useChatSSE.ts @@ -1,5 +1,6 @@ import { onBeforeUnmount, ref, type Ref } from 'vue' -import { refreshAccessToken } from '@/shared/api/client' +import axios from 'axios' +import { redirectToLogin, refreshAccessToken } from '@/shared/api/client' import { getAccessToken, type AuthScope } from '@/shared/utils/authStorage' export interface SSEMessage { @@ -93,8 +94,11 @@ export function useChatSSE(scope: AuthScope, endpoint: string) { if (!stopped) { reconnectTimer = setTimeout(connect, reconnectDelay) } - } catch { + } catch (error) { closeSource() + if (axios.isAxiosError(error) && error.response?.status === 401) { + redirectToLogin(scope) + } } finally { refreshing = false } diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index 1002ce2..a7703d9 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -174,7 +174,7 @@ function getRequestScope(url = ''): AuthScope { return url.startsWith('/admin') ? 'admin' : 'user' } -function redirectToLogin(scope: AuthScope) { +export function redirectToLogin(scope: AuthScope) { clearAuthStorage(scope) const currentPath = window.location.pathname + window.location.search const loginPath = getLoginPath(scope, currentPath) From 1f204f17ad2ff52ef81014b2ba9ac663c8e4e8ed Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 18 Aug 2026 20:15:06 +0800 Subject: [PATCH 18/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BA=BF=E4=B8=8B?= =?UTF-8?q?=E6=8F=90=E5=8F=B7=E8=B4=A2=E5=8A=A1=E8=B0=83=E6=95=B4=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/pickup/repository.go | 4 ++-- .../modules/pickup/repository_test.go | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 4ee045a..2552ebd 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -492,7 +492,7 @@ func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uin ActorType: "admin", ActorID: adminID, Action: "pickup_financial_adjustment_create", - BizType: "admin_pickup_financial_adjustment", + BizType: "admin_pickup", BizID: &bid, Meta: meta, Detail: map[string]any{ @@ -564,7 +564,7 @@ func (r *Repository) SettleFinancialAdjustment(ctx context.Context, adjustmentID ActorType: "admin", ActorID: adminID, Action: "pickup_financial_adjustment_settle", - BizType: "admin_pickup_financial_adjustment", + BizType: "admin_pickup", BizID: &bid, Meta: meta, Detail: map[string]any{ diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 589ebb1..1f1a5a2 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -469,6 +469,13 @@ func TestRepositoryFinancialAdjustmentUsesRecoveryForInternalReduction(t *testin if adjustment.Status != FinancialAdjustmentStatusRecovery || adjustment.SettleDeltaCent != -2000 { t.Fatalf("追回调整 = %+v", adjustment) } + var audit model.AuditLog + if err := db.Where("action = ?", "pickup_financial_adjustment_create").First(&audit).Error; err != nil { + t.Fatalf("查询财务调整审计日志失败: %v", err) + } + if audit.BizType != "admin_pickup" { + t.Fatalf("财务调整审计业务类型 = %q, want admin_pickup", audit.BizType) + } var ledgerCount int64 if err := db.Model(&model.WalletLedger{}).Where("biz_type = ?", "admin_pickup_adjustment").Count(&ledgerCount).Error; err != nil { t.Fatalf("统计补款流水失败: %v", err) @@ -512,6 +519,13 @@ func TestRepositoryFinancialAdjustmentSettlesPlatformManagedPickupSeparately(t * if adjustments[0].Status != FinancialAdjustmentStatusPending { t.Fatalf("代管补款状态 = %q, want pending", adjustments[0].Status) } + var createAudit model.AuditLog + if err := db.Where("action = ?", "pickup_financial_adjustment_create").First(&createAudit).Error; err != nil { + t.Fatalf("查询创建财务调整审计日志失败: %v", err) + } + if createAudit.BizType != "admin_pickup" { + t.Fatalf("创建财务调整审计业务类型 = %q, want admin_pickup", createAudit.BizType) + } if err := repo.SettleFinancialAdjustment(t.Context(), adjustments[0].ID, FinancialAdjustmentSettlementRequest{Remark: "已补款"}, 102, auditlog.Meta{}); err != nil { t.Fatalf("SettleFinancialAdjustment() error = %v", err) } @@ -519,6 +533,13 @@ func TestRepositoryFinancialAdjustmentSettlesPlatformManagedPickupSeparately(t * if err != nil || adjustments[0].Status != FinancialAdjustmentStatusSettled || adjustments[0].SettledBy == nil || *adjustments[0].SettledBy != 102 { t.Fatalf("确认后的调整流水 = %+v, %v", adjustments, err) } + var settleAudit model.AuditLog + if err := db.Where("action = ?", "pickup_financial_adjustment_settle").First(&settleAudit).Error; err != nil { + t.Fatalf("查询确认财务调整审计日志失败: %v", err) + } + if settleAudit.BizType != "admin_pickup" { + t.Fatalf("确认财务调整审计业务类型 = %q, want admin_pickup", settleAudit.BizType) + } } func TestRepositoryOfflineSettlementProcessesPendingPickupAdjustments(t *testing.T) { From c4507caf37fc87d49a9f4436778c78c93fa75094 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 18 Aug 2026 20:39:13 +0800 Subject: [PATCH 19/70] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=87=8C=E6=99=A8=E5=93=8D=E5=BA=94=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/listing/external_upload.go | 8 +-- .../internal/modules/listing/public_filter.go | 54 +++---------------- .../modules/listing/public_filter_test.go | 32 +++++------ .../internal/modules/listing/service_test.go | 3 ++ .../src/features/listings/views/HomeView.vue | 2 +- .../listings/views/MobileHomeView.vue | 2 +- .../seller/composables/usePublishDraft.ts | 1 + .../seller/composables/usePublishForm.ts | 5 ++ .../views/MobileSellerListingCreateView.css | 4 ++ .../views/MobileSellerListingCreateView.vue | 22 ++++++++ .../seller/views/SellerListingCreateView.css | 4 ++ .../seller/views/SellerListingCreateView.vue | 10 ++++ frontend/src/shared/types/publish.ts | 2 + 13 files changed, 79 insertions(+), 70 deletions(-) diff --git a/backend/internal/modules/listing/external_upload.go b/backend/internal/modules/listing/external_upload.go index 3c467cd..83832ca 100644 --- a/backend/internal/modules/listing/external_upload.go +++ b/backend/internal/modules/listing/external_upload.go @@ -275,9 +275,11 @@ func externalAccountToCreateRequest( "resources": resources, "skin_groups": externalSkinGroups(skins), "online_time_text": onlineTimeText, - "ban_record": normalizeBanRecord(item.BanRecord), - "common_regions": commonRegions(item.CommonRegion), - "remark": remark, + // 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。 + "early_morning_response": "否", + "ban_record": normalizeBanRecord(item.BanRecord), + "common_regions": commonRegions(item.CommonRegion), + "remark": remark, "import_meta": map[string]any{ "uploader_name": uploaderName, "client_upload_time": uploadTime, diff --git a/backend/internal/modules/listing/public_filter.go b/backend/internal/modules/listing/public_filter.go index 701de0e..2a0647a 100644 --- a/backend/internal/modules/listing/public_filter.go +++ b/backend/internal/modules/listing/public_filter.go @@ -403,53 +403,13 @@ func isNightAvailableSummary(summary map[string]any) bool { if summary == nil { return false } - onlineTime, ok := summary["online_time"].(map[string]any) - if !ok { + // 夜间专区只认号主明确确认的凌晨响应能力,旧数据缺失时默认不进入专区。 + switch value := summary["early_morning_response"].(type) { + case string: + return strings.TrimSpace(value) == "是" + case bool: + return value + default: return false } - start, okStart := parseTimeMinuteValue(onlineTime["start"]) - end, okEnd := parseTimeMinuteValue(onlineTime["end"]) - if !okStart || !okEnd { - return false - } - return timeRangeOverlapsMinutes(start, end, 0, 8*60) -} - -func parseTimeMinuteValue(value any) (int, bool) { - text, ok := value.(string) - if !ok { - return 0, false - } - parts := strings.Split(text, ":") - if len(parts) != 2 { - return 0, false - } - hour, err := strconv.Atoi(parts[0]) - if err != nil || hour < 0 || hour > 23 { - return 0, false - } - minute, err := strconv.Atoi(parts[1]) - if err != nil || minute < 0 || minute > 59 { - return 0, false - } - return hour*60 + minute, true -} - -func timeRangeOverlapsMinutes(start int, end int, targetStart int, targetEnd int) bool { - if start == end { - return true - } - for _, segment := range splitMinuteRange(start, end) { - if segment[0] <= targetEnd && segment[1] >= targetStart { - return true - } - } - return false -} - -func splitMinuteRange(start int, end int) [][2]int { - if start < end { - return [][2]int{{start, end}} - } - return [][2]int{{start, 23*60 + 59}, {0, end}} } diff --git a/backend/internal/modules/listing/public_filter_test.go b/backend/internal/modules/listing/public_filter_test.go index 7eb5145..bf8e9bb 100644 --- a/backend/internal/modules/listing/public_filter_test.go +++ b/backend/internal/modules/listing/public_filter_test.go @@ -178,31 +178,27 @@ func TestSortPublicListingsDefaultUsesShuffleKey(t *testing.T) { } } -func TestIsNightAvailableSummaryUsesMidnightToEight(t *testing.T) { +func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) { tests := []struct { - name string - start string - end string - want bool + name string + response any + want bool }{ - {name: "全天可上号", start: "00:00", end: "23:59", want: true}, - {name: "覆盖夜间新区间", start: "00:00", end: "08:00", want: true}, - {name: "刚好八点开始", start: "08:00", end: "12:00", want: true}, - {name: "八点半开始不算夜间", start: "08:30", end: "12:00", want: false}, - {name: "跨零点覆盖夜间", start: "23:00", end: "02:00", want: true}, - {name: "旧夜间晚间时段不再命中", start: "22:00", end: "23:00", want: false}, + {name: "明确响应", response: "是", want: true}, + {name: "明确不响应", response: "否", want: false}, + {name: "兼容布尔值", response: true, want: true}, + {name: "缺失字段", response: nil, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := isNightAvailableSummary(map[string]any{ - "online_time": map[string]any{ - "start": tt.start, - "end": tt.end, - }, - }) + summary := map[string]any{} + if tt.response != nil { + summary["early_morning_response"] = tt.response + } + got := isNightAvailableSummary(summary) if got != tt.want { - t.Fatalf("isNightAvailableSummary(%s-%s) = %v, want %v", tt.start, tt.end, got, tt.want) + t.Fatalf("isNightAvailableSummary(%#v) = %v, want %v", tt.response, got, tt.want) } }) } diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index c623461..21a014a 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -530,6 +530,9 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) { if req.AssetSummary["online_time_text"] != "08:00 至 01:00" { t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"]) } + if req.AssetSummary["early_morning_response"] != "否" { + t.Fatalf("early_morning_response = %#v, want 否", req.AssetSummary["early_morning_response"]) + } onlineTime, ok := req.AssetSummary["online_time"].(map[string]any) if !ok { t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"]) diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index 507927d..8856452 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -136,7 +136,7 @@ const zoneOptions = computed(() => [ { key: 'night', label: '夜间专区', - hint: '0-8点可上号', + hint: '0-2点可响应上号', count: zoneCount('night'), }, { diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index 5caab05..d646fd6 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -104,7 +104,7 @@ const zoneOptions = [ { key: 'all', label: '全部', hint: '当前可租' }, { key: 'sale', label: '特惠', hint: '价格更划算' }, { key: 'gift', label: '赠送', hint: '含赠送物品' }, - { key: 'night', label: '夜间', hint: '0-8点可上号' }, + { key: 'night', label: '夜间', hint: '0-2点可响应上号' }, { key: 'password', label: '账密', hint: '交接更快' }, { key: 'highCoin', label: '高币', hint: '100M以上' }, ] diff --git a/frontend/src/features/seller/composables/usePublishDraft.ts b/frontend/src/features/seller/composables/usePublishDraft.ts index 6ef97e8..08048de 100644 --- a/frontend/src/features/seller/composables/usePublishDraft.ts +++ b/frontend/src/features/seller/composables/usePublishDraft.ts @@ -17,6 +17,7 @@ export function defaultPublishForm(): PublishForm { login_method: '', online_start: '', online_end: '', + early_morning_response: '否', can_change_game_name: '', all_hero: '', ban_record: '', diff --git a/frontend/src/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts index cf343cd..23dd391 100644 --- a/frontend/src/features/seller/composables/usePublishForm.ts +++ b/frontend/src/features/seller/composables/usePublishForm.ts @@ -308,6 +308,7 @@ export function usePublishForm(options: UsePublishFormOptions) { login_method: listing.login_platform || '', online_start: stringValue(onlineTime.start), online_end: stringValue(onlineTime.end), + early_morning_response: stringValue(summary.early_morning_response) || '否', can_change_game_name: stringValue(summary.can_change_game_name), all_hero: stringValue(summary.all_hero), ban_record: stringValue(summary.ban_record), @@ -700,6 +701,9 @@ export function usePublishForm(options: UsePublishFormOptions) { if (!dailyLossOptions.includes(pricing.dailyLossMAmount.value)) return '请选择每日损耗' const onlineTimeError = validateOnlineTime() if (onlineTimeError) return onlineTimeError + if (form.early_morning_response !== '是' && form.early_morning_response !== '否') { + return '请选择凌晨0-2点是否可响应上号' + } if (pricing.hasAcceleratedSaleRatioInput()) { const ratio = Number(form.accelerated_sale_ratio) if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确' @@ -818,6 +822,7 @@ export function usePublishForm(options: UsePublishFormOptions) { start: form.online_start, end: form.online_end, }, + early_morning_response: form.early_morning_response, can_change_game_name: form.can_change_game_name, all_hero: form.all_hero, ban_record: form.ban_record, diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.css b/frontend/src/features/seller/views/MobileSellerListingCreateView.css index d5e03f7..6e6c8e0 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.css +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.css @@ -88,6 +88,10 @@ font-size: 16px; } +.early-morning-response-field :deep(.van-field__label) { + width: 128px; +} + .hint-label, .backend-hint-target.has-hint, .upload-title.has-hint { diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue index 120c630..b9bd082 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue @@ -121,6 +121,7 @@ function selectRadio(value: T, setter: (value: T) => void) { const canChangeGameNameOptions = ['是', '否'] const allHeroOptions = ['是', '否'] +const earlyMorningResponseOptions = ['是', '否'] // 在线时间:通栏自定义块 + 底部时间选择,避免 van-field 窄列截断原生 time const onlineTimePickerVisible = ref(false) @@ -481,6 +482,27 @@ const screenshotGuides: Record = { 请填写能百分百联系上您的时间。该时段联系不上可能影响上架或扣款,请预留扫码与冻结人脸时间。

+ + + + activeAgreement.value?.content || const canChangeGameNameOptions = ['是', '否'] const allHeroOptions = ['是', '否'] +const earlyMorningResponseOptions = ['是', '否'] // 截图槽位的指导示例图(仅展示、点击放大,不占用上传数量) const screenshotGuides: Record = { @@ -396,6 +397,15 @@ function selectDailyLoss(value: string | number) {
+
+ + +
+
Date: Tue, 18 Aug 2026 20:45:09 +0800 Subject: [PATCH 20/70] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E4=B8=AD=E7=9A=84=E4=B8=89=E4=B8=AA=E4=BD=8E?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../composables/__tests__/useFilterOptions.spec.ts | 14 +++++++++++++- .../listings/composables/useFilterOptions.ts | 5 +---- .../src/features/listings/views/MobileHomeView.vue | 4 ---- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts b/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts index 35807e7..0fd9c9e 100644 --- a/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts +++ b/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest' -import { rangeLabel, isRangeSelected } from '@/features/listings/composables/useFilterOptions' +import { + awmRangeOptions, + isRangeSelected, + moneyRangeOptions, + rangeLabel, + ratioRangeOptions, +} from '@/features/listings/composables/useFilterOptions' describe('useFilterOptions', () => { describe('rangeLabel', () => { @@ -27,4 +33,10 @@ describe('useFilterOptions', () => { expect(isRangeSelected(undefined, undefined, undefined, undefined)).toBe(true) }) }) + + it('应该隐藏数量过小的首页筛选档位', () => { + expect(awmRangeOptions.some(option => option.label === '0-20')).toBe(false) + expect(moneyRangeOptions.some(option => option.label === '0-50')).toBe(false) + expect(ratioRangeOptions.some(option => option.label === '≤30')).toBe(false) + }) }) diff --git a/frontend/src/features/listings/composables/useFilterOptions.ts b/frontend/src/features/listings/composables/useFilterOptions.ts index b9e0478..f9c3ef9 100644 --- a/frontend/src/features/listings/composables/useFilterOptions.ts +++ b/frontend/src/features/listings/composables/useFilterOptions.ts @@ -9,10 +9,9 @@ export const coinRangeOptions = [ { label: '300以上', min: 300, max: undefined }, ] -/** AWM 子弹数量区间(与移动端档位保持一致) */ +/** AWM 子弹数量区间:移除数量过小的 0-20 档位 */ export const awmRangeOptions = [ { label: '全部', min: undefined, max: undefined }, - { label: '0-20', min: 0, max: 20 }, { label: '20-50', min: 20, max: 50 }, { label: '50-100', min: 50, max: 100 }, { label: '100-200', min: 100, max: 200 }, @@ -21,7 +20,6 @@ export const awmRangeOptions = [ export const ratioRangeOptions = [ { label: '全部', min: undefined, max: undefined }, - { label: '≤30', min: undefined, max: 30 }, { label: '30-35', min: 30, max: 35 }, { label: '35-40', min: 35, max: 40 }, { label: '40-45', min: 40, max: 45 }, @@ -30,7 +28,6 @@ export const ratioRangeOptions = [ export const moneyRangeOptions = [ { label: '全部', min: undefined, max: undefined }, - { label: '0-50', min: 0, max: 50 }, { label: '50-100', min: 50, max: 100 }, { label: '100-150', min: 100, max: 150 }, { label: '150-200', min: 150, max: 200 }, diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index d646fd6..9c1bbb9 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -111,7 +111,6 @@ const zoneOptions = [ const rangePresets: Record> = { price: [ - { label: '0-50', min: '0', max: '50' }, { label: '50-100', min: '50', max: '100' }, { label: '100-200', min: '100', max: '200' }, { label: '200以上', min: '200', max: '' }, @@ -123,8 +122,6 @@ const rangePresets: Record Date: Thu, 20 Aug 2026 15:48:25 +0800 Subject: [PATCH 21/70] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E7=BA=BF=E4=B8=8B=E5=87=BA=E6=AC=BE=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/adminfinance/disbursement_list.go | 127 ++++++++--------- .../adminfinance/disbursement_list_test.go | 10 +- backend/internal/modules/adminfinance/dto.go | 107 ++++++++------- .../adminfinance/manual_disbursement.go | 129 +++++++++--------- .../adminfinance/manual_disbursement_test.go | 24 ++-- .../internal/modules/adminfinance/service.go | 11 +- ...53_manual_disbursement_custom_category.sql | 9 ++ .../src/features/admin/api/adminFinance.ts | 3 + .../views/AdminFinanceDisbursementsView.vue | 29 +++- 9 files changed, 256 insertions(+), 193 deletions(-) create mode 100644 backend/migrations/000053_manual_disbursement_custom_category.sql diff --git a/backend/internal/modules/adminfinance/disbursement_list.go b/backend/internal/modules/adminfinance/disbursement_list.go index 82ab7c9..8b390ed 100644 --- a/backend/internal/modules/adminfinance/disbursement_list.go +++ b/backend/internal/modules/adminfinance/disbursement_list.go @@ -80,6 +80,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, ro.offline_settlement_remark AS remark, '' AS category, + '' AS custom_category_name, '' AS voucher_url, NULL AS voided_at, NULL AS voided_by, @@ -125,6 +126,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark, '' AS category, + '' AS custom_category_name, '' AS voucher_url, NULL AS voided_at, NULL AS voided_by, @@ -160,6 +162,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name, md.remark, md.category, + md.custom_category_name, md.voucher_url, md.voided_at, md.voided_by, @@ -202,37 +205,38 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue } type disbursementItemRow struct { - SourceType string - SourceID uint64 - BusinessNo string - OrderID uint64 - WithdrawalID uint64 - UserID uint64 - PayeeName string - PayeePhone string - UploaderName string - SourceChannel string - AccountType string - AccountName string - AccountNo string - BankName string - AmountCent int64 - FeeCent int64 - ActualAmountCent int64 - Status string - RawStatus string - BusinessCreatedAt *time.Time - PaidAt *time.Time - OperatorID *uint64 - OperatorName string - Remark string - Category string - VoucherURL string - VoidedAt *time.Time - VoidedBy *uint64 - VoidedByName string - VoidReason string - SourcePayload []byte + SourceType string + SourceID uint64 + BusinessNo string + OrderID uint64 + WithdrawalID uint64 + UserID uint64 + PayeeName string + PayeePhone string + UploaderName string + SourceChannel string + AccountType string + AccountName string + AccountNo string + BankName string + AmountCent int64 + FeeCent int64 + ActualAmountCent int64 + Status string + RawStatus string + BusinessCreatedAt *time.Time + PaidAt *time.Time + OperatorID *uint64 + OperatorName string + Remark string + Category string + CustomCategoryName string + VoucherURL string + VoidedAt *time.Time + VoidedBy *uint64 + VoidedByName string + VoidReason string + SourcePayload []byte } func (r disbursementItemRow) toDTO() DisbursementItemDTO { @@ -246,35 +250,36 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO { } } return DisbursementItemDTO{ - SourceType: r.SourceType, - SourceID: r.SourceID, - BusinessNo: r.BusinessNo, - OrderID: r.OrderID, - WithdrawalID: r.WithdrawalID, - UserID: r.UserID, - PayeeName: r.PayeeName, - PayeePhone: phone, - UploaderName: r.UploaderName, - SourceChannel: r.SourceChannel, - AccountType: r.AccountType, - AccountName: r.AccountName, - AccountNo: r.AccountNo, - BankName: r.BankName, - AmountCent: r.AmountCent, - FeeCent: r.FeeCent, - ActualAmountCent: r.ActualAmountCent, - Status: r.Status, - RawStatus: r.RawStatus, - BusinessCreatedAt: r.BusinessCreatedAt, - PaidAt: r.PaidAt, - OperatorID: r.OperatorID, - OperatorName: r.OperatorName, - Remark: r.Remark, - Category: r.Category, - VoucherURL: r.VoucherURL, - VoidedAt: r.VoidedAt, - VoidedBy: r.VoidedBy, - VoidedByName: r.VoidedByName, - VoidReason: r.VoidReason, + SourceType: r.SourceType, + SourceID: r.SourceID, + BusinessNo: r.BusinessNo, + OrderID: r.OrderID, + WithdrawalID: r.WithdrawalID, + UserID: r.UserID, + PayeeName: r.PayeeName, + PayeePhone: phone, + UploaderName: r.UploaderName, + SourceChannel: r.SourceChannel, + AccountType: r.AccountType, + AccountName: r.AccountName, + AccountNo: r.AccountNo, + BankName: r.BankName, + AmountCent: r.AmountCent, + FeeCent: r.FeeCent, + ActualAmountCent: r.ActualAmountCent, + Status: r.Status, + RawStatus: r.RawStatus, + BusinessCreatedAt: r.BusinessCreatedAt, + PaidAt: r.PaidAt, + OperatorID: r.OperatorID, + OperatorName: r.OperatorName, + Remark: r.Remark, + Category: r.Category, + CustomCategoryName: r.CustomCategoryName, + VoucherURL: r.VoucherURL, + VoidedAt: r.VoidedAt, + VoidedBy: r.VoidedBy, + VoidedByName: r.VoidedByName, + VoidReason: r.VoidReason, } } diff --git a/backend/internal/modules/adminfinance/disbursement_list_test.go b/backend/internal/modules/adminfinance/disbursement_list_test.go index 842de0f..1b463c9 100644 --- a/backend/internal/modules/adminfinance/disbursement_list_test.go +++ b/backend/internal/modules/adminfinance/disbursement_list_test.go @@ -37,7 +37,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) `CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`, `CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`, `CREATE TABLE manual_disbursements ( - id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, payee_name TEXT, + id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT, amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT, status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME, void_reason TEXT, created_at DATETIME @@ -87,11 +87,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) t.Fatal(err) } if err := db.Exec(`INSERT INTO manual_disbursements - (id, disbursement_no, category, payee_name, amount_cent, paid_at, remark, + (id, disbursement_no, category, custom_category_name, payee_name, amount_cent, paid_at, remark, voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at) VALUES - (20, 'OD202607010001', 'user_compensation', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?), - (21, 'OD202607010002', 'other', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`, + (20, 'OD202607010001', 'custom', '临时活动支出', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?), + (21, 'OD202607010002', 'other', '', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`, at(6), at(5), at(7), at(8), at(5)).Error; err != nil { t.Fatal(err) } @@ -142,7 +142,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) break } } - if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "user_compensation" || manualItem.OperatorName != "财务甲" { + if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "custom" || manualItem.CustomCategoryName != "临时活动支出" || manualItem.OperatorName != "财务甲" { t.Fatalf("其他线下出款信息不正确: %+v", manualItem) } diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go index aa3981a..26aff42 100644 --- a/backend/internal/modules/adminfinance/dto.go +++ b/backend/internal/modules/adminfinance/dto.go @@ -102,45 +102,47 @@ type DisbursementListSummaryDTO struct { } type DisbursementItemDTO struct { - SourceType string `json:"source_type"` - SourceID uint64 `json:"source_id"` - BusinessNo string `json:"business_no"` - OrderID uint64 `json:"order_id,omitempty"` - WithdrawalID uint64 `json:"withdrawal_id,omitempty"` - UserID uint64 `json:"user_id"` - PayeeName string `json:"payee_name"` - PayeePhone string `json:"payee_phone"` - UploaderName string `json:"uploader_name"` - SourceChannel string `json:"source_channel"` - AccountType string `json:"account_type"` - AccountName string `json:"account_name"` - AccountNo string `json:"account_no"` - BankName string `json:"bank_name"` - AmountCent int64 `json:"amount_cent"` - FeeCent int64 `json:"fee_cent"` - ActualAmountCent int64 `json:"actual_amount_cent"` - Status string `json:"status"` - RawStatus string `json:"raw_status"` - BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"` - PaidAt *time.Time `json:"paid_at,omitempty"` - OperatorID *uint64 `json:"operator_id,omitempty"` - OperatorName string `json:"operator_name"` - Remark string `json:"remark"` - Category string `json:"category"` - VoucherURL string `json:"voucher_url"` - VoidedAt *time.Time `json:"voided_at,omitempty"` - VoidedBy *uint64 `json:"voided_by,omitempty"` - VoidedByName string `json:"voided_by_name"` - VoidReason string `json:"void_reason"` + SourceType string `json:"source_type"` + SourceID uint64 `json:"source_id"` + BusinessNo string `json:"business_no"` + OrderID uint64 `json:"order_id,omitempty"` + WithdrawalID uint64 `json:"withdrawal_id,omitempty"` + UserID uint64 `json:"user_id"` + PayeeName string `json:"payee_name"` + PayeePhone string `json:"payee_phone"` + UploaderName string `json:"uploader_name"` + SourceChannel string `json:"source_channel"` + AccountType string `json:"account_type"` + AccountName string `json:"account_name"` + AccountNo string `json:"account_no"` + BankName string `json:"bank_name"` + AmountCent int64 `json:"amount_cent"` + FeeCent int64 `json:"fee_cent"` + ActualAmountCent int64 `json:"actual_amount_cent"` + Status string `json:"status"` + RawStatus string `json:"raw_status"` + BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"` + PaidAt *time.Time `json:"paid_at,omitempty"` + OperatorID *uint64 `json:"operator_id,omitempty"` + OperatorName string `json:"operator_name"` + Remark string `json:"remark"` + Category string `json:"category"` + CustomCategoryName string `json:"custom_category_name"` + VoucherURL string `json:"voucher_url"` + VoidedAt *time.Time `json:"voided_at,omitempty"` + VoidedBy *uint64 `json:"voided_by,omitempty"` + VoidedByName string `json:"voided_by_name"` + VoidReason string `json:"void_reason"` } type CreateManualDisbursementRequest struct { - Category string `json:"category"` - PayeeName string `json:"payee_name"` - AmountCent int64 `json:"amount_cent"` - PaidAt time.Time `json:"paid_at"` - Remark string `json:"remark"` - VoucherURL string `json:"voucher_url"` + Category string `json:"category"` + CustomCategoryName string `json:"custom_category_name"` + PayeeName string `json:"payee_name"` + AmountCent int64 `json:"amount_cent"` + PaidAt time.Time `json:"paid_at"` + Remark string `json:"remark"` + VoucherURL string `json:"voucher_url"` } type VoidManualDisbursementRequest struct { @@ -148,22 +150,23 @@ type VoidManualDisbursementRequest struct { } type ManualDisbursementDTO struct { - ID uint64 `json:"id"` - DisbursementNo string `json:"disbursement_no"` - Category string `json:"category"` - PayeeName string `json:"payee_name"` - AmountCent int64 `json:"amount_cent"` - PaidAt time.Time `json:"paid_at"` - Remark string `json:"remark"` - VoucherURL string `json:"voucher_url"` - Status string `json:"status"` - CreatedBy uint64 `json:"created_by"` - CreatedByName string `json:"created_by_name"` - VoidedBy *uint64 `json:"voided_by,omitempty"` - VoidedByName string `json:"voided_by_name"` - VoidedAt *time.Time `json:"voided_at,omitempty"` - VoidReason string `json:"void_reason"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `json:"id"` + DisbursementNo string `json:"disbursement_no"` + Category string `json:"category"` + CustomCategoryName string `json:"custom_category_name"` + PayeeName string `json:"payee_name"` + AmountCent int64 `json:"amount_cent"` + PaidAt time.Time `json:"paid_at"` + Remark string `json:"remark"` + VoucherURL string `json:"voucher_url"` + Status string `json:"status"` + CreatedBy uint64 `json:"created_by"` + CreatedByName string `json:"created_by_name"` + VoidedBy *uint64 `json:"voided_by,omitempty"` + VoidedByName string `json:"voided_by_name"` + VoidedAt *time.Time `json:"voided_at,omitempty"` + VoidReason string `json:"void_reason"` + CreatedAt time.Time `json:"created_at"` } type FinanceSummaryDTO struct { diff --git a/backend/internal/modules/adminfinance/manual_disbursement.go b/backend/internal/modules/adminfinance/manual_disbursement.go index d8e954b..1c83c24 100644 --- a/backend/internal/modules/adminfinance/manual_disbursement.go +++ b/backend/internal/modules/adminfinance/manual_disbursement.go @@ -16,21 +16,22 @@ import ( ) type manualDisbursementRecord struct { - ID uint64 - DisbursementNo string - Category string - PayeeName string - AmountCent int64 - PaidAt time.Time - Remark string - VoucherURL string - Status string - CreatedBy uint64 - VoidedBy *uint64 - VoidedAt *time.Time - VoidReason string - CreatedAt time.Time - UpdatedAt time.Time + ID uint64 + DisbursementNo string + Category string + CustomCategoryName string + PayeeName string + AmountCent int64 + PaidAt time.Time + Remark string + VoucherURL string + Status string + CreatedBy uint64 + VoidedBy *uint64 + VoidedAt *time.Time + VoidReason string + CreatedAt time.Time + UpdatedAt time.Time } func (manualDisbursementRecord) TableName() string { @@ -48,15 +49,16 @@ func (r *Repository) CreateManualDisbursement( return nil, err } record := manualDisbursementRecord{ - DisbursementNo: disbursementNo, - Category: req.Category, - PayeeName: req.PayeeName, - AmountCent: req.AmountCent, - PaidAt: req.PaidAt, - Remark: req.Remark, - VoucherURL: req.VoucherURL, - Status: "paid", - CreatedBy: adminID, + DisbursementNo: disbursementNo, + Category: req.Category, + CustomCategoryName: req.CustomCategoryName, + PayeeName: req.PayeeName, + AmountCent: req.AmountCent, + PaidAt: req.PaidAt, + Remark: req.Remark, + VoucherURL: req.VoucherURL, + Status: "paid", + CreatedBy: adminID, } err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&record).Error; err != nil { @@ -71,12 +73,13 @@ func (r *Repository) CreateManualDisbursement( BizID: &id, Meta: meta, Detail: map[string]any{ - "disbursement_no": disbursementNo, - "category": req.Category, - "payee_name": req.PayeeName, - "amount_cent": req.AmountCent, - "paid_at": req.PaidAt, - "has_voucher": req.VoucherURL != "", + "disbursement_no": disbursementNo, + "category": req.Category, + "custom_category_name": req.CustomCategoryName, + "payee_name": req.PayeeName, + "amount_cent": req.AmountCent, + "paid_at": req.PaidAt, + "has_voucher": req.VoucherURL != "", }, }) }) @@ -153,42 +156,44 @@ func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*Ma return nil, err } return &ManualDisbursementDTO{ - ID: row.ID, - DisbursementNo: row.DisbursementNo, - Category: row.Category, - PayeeName: row.PayeeName, - AmountCent: row.AmountCent, - PaidAt: row.PaidAt, - Remark: row.Remark, - VoucherURL: row.VoucherURL, - Status: row.Status, - CreatedBy: row.CreatedBy, - CreatedByName: row.CreatedByName, - VoidedBy: row.VoidedBy, - VoidedByName: row.VoidedByName, - VoidedAt: row.VoidedAt, - VoidReason: row.VoidReason, - CreatedAt: row.CreatedAt, + ID: row.ID, + DisbursementNo: row.DisbursementNo, + Category: row.Category, + CustomCategoryName: row.CustomCategoryName, + PayeeName: row.PayeeName, + AmountCent: row.AmountCent, + PaidAt: row.PaidAt, + Remark: row.Remark, + VoucherURL: row.VoucherURL, + Status: row.Status, + CreatedBy: row.CreatedBy, + CreatedByName: row.CreatedByName, + VoidedBy: row.VoidedBy, + VoidedByName: row.VoidedByName, + VoidedAt: row.VoidedAt, + VoidReason: row.VoidReason, + CreatedAt: row.CreatedAt, }, nil } type manualDisbursementDetailRow struct { - ID uint64 - DisbursementNo string - Category string - PayeeName string - AmountCent int64 - PaidAt time.Time - Remark string - VoucherURL string - Status string - CreatedBy uint64 - CreatedByName string - VoidedBy *uint64 - VoidedByName string - VoidedAt *time.Time - VoidReason string - CreatedAt time.Time + ID uint64 + DisbursementNo string + Category string + CustomCategoryName string + PayeeName string + AmountCent int64 + PaidAt time.Time + Remark string + VoucherURL string + Status string + CreatedBy uint64 + CreatedByName string + VoidedBy *uint64 + VoidedByName string + VoidedAt *time.Time + VoidReason string + CreatedAt time.Time } func newManualDisbursementNo() (string, error) { diff --git a/backend/internal/modules/adminfinance/manual_disbursement_test.go b/backend/internal/modules/adminfinance/manual_disbursement_test.go index 7f712e1..f9df480 100644 --- a/backend/internal/modules/adminfinance/manual_disbursement_test.go +++ b/backend/internal/modules/adminfinance/manual_disbursement_test.go @@ -21,7 +21,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) { statements := []string{ `CREATE TABLE manual_disbursements ( id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE, - category TEXT NOT NULL, payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, + category TEXT NOT NULL, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT NOT NULL, status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER, voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME, @@ -47,17 +47,18 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) { repo := NewRepository(db) paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation()) created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ - Category: "operating_expense", - PayeeName: "测试供应商", - AmountCent: 8800, - PaidAt: paidAt, - Remark: "测试运营支出", - VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp", + Category: "custom", + CustomCategoryName: "临时活动支出", + PayeeName: "测试供应商", + AmountCent: 8800, + PaidAt: paidAt, + Remark: "测试运营支出", + VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp", }, 7, auditlog.Meta{RequestID: "req-create"}) if err != nil { t.Fatalf("创建其他线下出款失败: %v", err) } - if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" { + if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" || created.CustomCategoryName != "临时活动支出" { t.Fatalf("创建结果不正确: %+v", created) } @@ -82,6 +83,13 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) { } func TestManualDisbursementValidation(t *testing.T) { + if !validManualDisbursementCategory("duoduo_deposit_refund") { + t.Fatal("多多退押金分类应为有效分类") + } + if !validManualDisbursementCustomCategoryName("custom", "临时活动支出") || validManualDisbursementCustomCategoryName("custom", "") { + t.Fatal("自定义分类名称校验不正确") + } + service := NewService(&Repository{}) _, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ Category: "unknown", diff --git a/backend/internal/modules/adminfinance/service.go b/backend/internal/modules/adminfinance/service.go index 888972f..cbd5f48 100644 --- a/backend/internal/modules/adminfinance/service.go +++ b/backend/internal/modules/adminfinance/service.go @@ -74,10 +74,12 @@ func (s *Service) CreateManualDisbursement( return nil, ErrDependencyUnavailable } req.Category = strings.TrimSpace(req.Category) + req.CustomCategoryName = strings.TrimSpace(req.CustomCategoryName) req.PayeeName = strings.TrimSpace(req.PayeeName) req.Remark = strings.TrimSpace(req.Remark) req.VoucherURL = strings.TrimSpace(req.VoucherURL) if !validManualDisbursementCategory(req.Category) || + !validManualDisbursementCustomCategoryName(req.Category, req.CustomCategoryName) || utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 || req.AmountCent <= 0 || req.PaidAt.IsZero() || utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 || @@ -106,13 +108,20 @@ func (s *Service) VoidManualDisbursement( func validManualDisbursementCategory(category string) bool { switch category { - case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other": + case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom": return true default: return false } } +func validManualDisbursementCustomCategoryName(category, name string) bool { + if category != "custom" { + return name == "" + } + return utf8.RuneCountInString(name) >= 1 && utf8.RuneCountInString(name) <= 50 +} + func validManualVoucherURL(raw string) bool { if raw == "" { return true diff --git a/backend/migrations/000053_manual_disbursement_custom_category.sql b/backend/migrations/000053_manual_disbursement_custom_category.sql new file mode 100644 index 0000000..092fa68 --- /dev/null +++ b/backend/migrations/000053_manual_disbursement_custom_category.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE manual_disbursements + ADD COLUMN custom_category_name VARCHAR(50) NOT NULL DEFAULT '' COMMENT '自定义出款分类名称'; + +-- +goose Down + +ALTER TABLE manual_disbursements + DROP COLUMN custom_category_name; diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index fb27d32..af0ed3c 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -144,6 +144,7 @@ export interface FinanceDisbursementItem { operator_name: string remark: string category: string + custom_category_name: string voucher_url: string voided_at?: string voided_by?: number @@ -153,6 +154,7 @@ export interface FinanceDisbursementItem { export interface CreateManualDisbursementPayload { category: string + custom_category_name?: string payee_name: string amount_cent: number paid_at: string @@ -164,6 +166,7 @@ export interface ManualDisbursement { id: number disbursement_no: string category: string + custom_category_name: string payee_name: string amount_cent: number paid_at: string diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue index 232c913..8091178 100644 --- a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue @@ -42,10 +42,13 @@ const manualCategories = [ { value: 'seller_supplement', label: '卖家补款' }, { value: 'operating_expense', label: '运营支出' }, { value: 'channel_fee', label: '渠道费用' }, + { value: 'duoduo_deposit_refund', label: '多多退押金' }, { value: 'other', label: '其他' }, + { value: 'custom', label: '自定义分类' }, ] const createForm = reactive({ category: '', + custom_category_name: '', payee_name: '', amount_yuan: 0, paid_at: new Date(), @@ -130,6 +133,7 @@ function openCreate() { function resetCreateForm() { createForm.category = '' + createForm.custom_category_name = '' createForm.payee_name = '' createForm.amount_yuan = 0 createForm.paid_at = new Date() @@ -169,10 +173,16 @@ async function submitManualDisbursement() { ElMessage.warning('请填写出款用途') return } + if (createForm.category === 'custom' && !createForm.custom_category_name.trim()) { + ElMessage.warning('请填写自定义分类名称') + return + } createSaving.value = true try { await createManualDisbursement({ category: createForm.category, + custom_category_name: + createForm.category === 'custom' ? createForm.custom_category_name.trim() : undefined, payee_name: createForm.payee_name.trim(), amount_cent: amountCent, paid_at: createForm.paid_at.toISOString(), @@ -283,7 +293,8 @@ function itemStatusLabel(row: FinanceDisbursementItem) { return statusLabel(row.status) } -function categoryLabel(category: string) { +function categoryLabel(category: string, customCategoryName = '') { + if (category === 'custom' && customCategoryName) return customCategoryName return manualCategories.find(item => item.value === category)?.label || category || '-' } @@ -429,7 +440,7 @@ function accountTypeLabel(type: string) { {{ row.payee_phone }} 用户 ID {{ row.user_id }} - {{ categoryLabel(row.category) }} + {{ categoryLabel(row.category, row.custom_category_name) }} 联系方式未登记 上传:{{ row.uploader_name }} @@ -445,7 +456,9 @@ function accountTypeLabel(type: string) { {{ row.bank_name }}
- {{ categoryLabel(row.category) }} + + {{ categoryLabel(row.category, row.custom_category_name) }} + {{ row.voucher_url ? '已上传付款凭证' : '未上传付款凭证' }}
线下约定 @@ -545,6 +558,14 @@ function accountTypeLabel(type: string) { /> + + + @@ -634,7 +655,7 @@ function accountTypeLabel(type: string) { - {{ categoryLabel(selectedManual.category) }} + {{ categoryLabel(selectedManual.category, selectedManual.custom_category_name) }} {{ selectedManual.payee_name }} From 80e329a6042d7418365962c346a6c9fbee76fbed Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 20 Aug 2026 16:06:05 +0800 Subject: [PATCH 22/70] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E5=BC=80=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/adminfinance/dashboard.go | 42 +- .../modules/adminfinance/disbursement.go | 17 + backend/internal/modules/adminfinance/dto.go | 77 +++- .../internal/modules/adminfinance/handler.go | 90 ++++ .../modules/adminfinance/operating_expense.go | 234 ++++++++++ .../adminfinance/operating_expense_test.go | 117 +++++ .../internal/modules/adminfinance/service.go | 48 ++ backend/internal/router/router.go | 3 + .../migrations/000054_operating_expenses.sql | 44 ++ .../src/features/admin/api/adminFinance.ts | 94 ++++ .../admin/views/AdminFinanceDashboardView.vue | 12 + .../views/AdminOperatingExpensesView.vue | 411 ++++++++++++++++++ frontend/src/layouts/AdminLayout.vue | 6 + frontend/src/router/adminRoutes.ts | 6 + 14 files changed, 1189 insertions(+), 12 deletions(-) create mode 100644 backend/internal/modules/adminfinance/operating_expense.go create mode 100644 backend/internal/modules/adminfinance/operating_expense_test.go create mode 100644 backend/migrations/000054_operating_expenses.sql create mode 100644 frontend/src/features/admin/views/AdminOperatingExpensesView.vue diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 88d7f41..dcd7ae1 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -27,16 +27,34 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash if err != nil { return nil, err } + operatingExpense, err := r.operatingExpenseSummary(ctx, query) + if err != nil { + return nil, err + } return &DashboardDTO{ - Summary: *summary, - DailyItems: dailyItems, - PickupSummary: *pickup, - MohongSummary: *mohong, - DisbursementSummary: *disbursement, - GeneratedAt: timeutil.ShanghaiNow(), + Summary: *summary, + DailyItems: dailyItems, + PickupSummary: *pickup, + MohongSummary: *mohong, + DisbursementSummary: *disbursement, + OperatingExpenseSummary: *operatingExpense, + GeneratedAt: timeutil.ShanghaiNow(), }, nil } +func (r *Repository) operatingExpenseSummary(ctx context.Context, query DashboardQuery) (*OperatingExpenseSummaryDTO, error) { + var summary OperatingExpenseSummaryDTO + err := r.db.WithContext(ctx).Table("operating_expenses"). + Select("COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count"). + Where("status = ?", "paid"). + Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate). + Scan(&summary).Error + if err != nil { + return nil, err + } + return &summary, nil +} + // pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。 func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*PickupSummaryDTO, error) { db := r.db.WithContext(ctx) @@ -303,6 +321,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi if err != nil { return nil, err } + operatingExpenses, err := r.dailyOperatingExpenses(ctx, query) + if err != nil { + return nil, err + } itemsByDate := make(map[string]FinanceDailyDTO) for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { @@ -392,6 +414,14 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount itemsByDate[date] = item } + for _, row := range operatingExpenses { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.OperatingExpenseAmountCent = row.AmountCent + item.OperatingExpenseCount = row.Count + itemsByDate[date] = item + } items := make([]FinanceDailyDTO, 0, len(itemsByDate)) for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { diff --git a/backend/internal/modules/adminfinance/disbursement.go b/backend/internal/modules/adminfinance/disbursement.go index 02c40bb..84ecaa2 100644 --- a/backend/internal/modules/adminfinance/disbursement.go +++ b/backend/internal/modules/adminfinance/disbursement.go @@ -2,6 +2,23 @@ package adminfinance import "context" +type dailyOperatingExpenseRow struct { + Date string + AmountCent int64 + Count int64 +} + +func (r *Repository) dailyOperatingExpenses(ctx context.Context, query DashboardQuery) ([]dailyOperatingExpenseRow, error) { + rows := make([]dailyOperatingExpenseRow, 0) + err := r.db.WithContext(ctx).Table("operating_expenses"). + Select(`DATE(occurred_at) AS date, COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("status = ?", "paid"). + Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate). + Group("DATE(occurred_at)"). + Scan(&rows).Error + return rows, err +} + // disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。 func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) { db := r.db.WithContext(ctx) diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go index 26aff42..c6e005e 100644 --- a/backend/internal/modules/adminfinance/dto.go +++ b/backend/internal/modules/adminfinance/dto.go @@ -34,13 +34,25 @@ type DisbursementQuery struct { PageSize int } +type OperatingExpenseQuery struct { + Status string + Category string + Keyword string + DateType string + StartDate time.Time + EndDate time.Time + Page int + PageSize int +} + type DashboardDTO struct { - Summary FinanceSummaryDTO `json:"summary"` - DailyItems []FinanceDailyDTO `json:"daily_items"` - PickupSummary PickupSummaryDTO `json:"pickup_summary"` - MohongSummary MohongSummaryDTO `json:"mohong_summary"` - DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"` - GeneratedAt time.Time `json:"generated_at"` + Summary FinanceSummaryDTO `json:"summary"` + DailyItems []FinanceDailyDTO `json:"daily_items"` + PickupSummary PickupSummaryDTO `json:"pickup_summary"` + MohongSummary MohongSummaryDTO `json:"mohong_summary"` + DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"` + OperatingExpenseSummary OperatingExpenseSummaryDTO `json:"operating_expense_summary"` + GeneratedAt time.Time `json:"generated_at"` } // PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。 @@ -101,6 +113,25 @@ type DisbursementListSummaryDTO struct { WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"` } +type OperatingExpenseSummaryDTO struct { + AmountCent int64 `json:"amount_cent"` + Count int64 `json:"count"` +} + +type OperatingExpenseListSummaryDTO struct { + RecordCount int64 `json:"record_count"` + PaidAmountCent int64 `json:"paid_amount_cent"` + PaidCount int64 `json:"paid_count"` +} + +type OperatingExpenseListDTO struct { + Items []OperatingExpenseDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Summary OperatingExpenseListSummaryDTO `json:"summary"` +} + type DisbursementItemDTO struct { SourceType string `json:"source_type"` SourceID uint64 `json:"source_id"` @@ -169,6 +200,38 @@ type ManualDisbursementDTO struct { CreatedAt time.Time `json:"created_at"` } +type CreateOperatingExpenseRequest struct { + Category string `json:"category"` + PayeeName string `json:"payee_name"` + AmountCent int64 `json:"amount_cent"` + OccurredAt time.Time `json:"occurred_at"` + Remark string `json:"remark"` + VoucherURL string `json:"voucher_url"` +} + +type VoidOperatingExpenseRequest struct { + Reason string `json:"reason"` +} + +type OperatingExpenseDTO struct { + ID uint64 `json:"id"` + ExpenseNo string `json:"expense_no"` + Category string `json:"category"` + PayeeName string `json:"payee_name"` + AmountCent int64 `json:"amount_cent"` + OccurredAt time.Time `json:"occurred_at"` + Remark string `json:"remark"` + VoucherURL string `json:"voucher_url"` + Status string `json:"status"` + CreatedBy uint64 `json:"created_by"` + CreatedByName string `json:"created_by_name"` + VoidedBy *uint64 `json:"voided_by,omitempty"` + VoidedByName string `json:"voided_by_name"` + VoidedAt *time.Time `json:"voided_at,omitempty"` + VoidReason string `json:"void_reason"` + CreatedAt time.Time `json:"created_at"` +} + type FinanceSummaryDTO struct { TotalFlowAmountCent int64 `json:"total_flow_amount_cent"` TotalRefundAmountCent int64 `json:"total_refund_amount_cent"` @@ -233,6 +296,8 @@ type FinanceDailyDTO struct { WithdrawalPaidCount int64 `json:"withdrawal_paid_count"` ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"` ManualPaidCount int64 `json:"manual_paid_count"` + OperatingExpenseAmountCent int64 `json:"operating_expense_amount_cent"` + OperatingExpenseCount int64 `json:"operating_expense_count"` DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"` DisbursementPaidCount int64 `json:"disbursement_paid_count"` } diff --git a/backend/internal/modules/adminfinance/handler.go b/backend/internal/modules/adminfinance/handler.go index 99ab947..5e90f84 100644 --- a/backend/internal/modules/adminfinance/handler.go +++ b/backend/internal/modules/adminfinance/handler.go @@ -62,6 +62,19 @@ func (h *Handler) Disbursements(c *gin.Context) { response.OK(c, result) } +func (h *Handler) OperatingExpenses(c *gin.Context) { + query, ok := parseOperatingExpenseQuery(c) + if !ok { + return + } + result, err := h.service.OperatingExpenses(c.Request.Context(), query) + if err != nil { + writeFinanceError(c, err) + return + } + response.OK(c, result) +} + func (h *Handler) CreateManualDisbursement(c *gin.Context) { adminID, ok := financeAdminID(c) if !ok { @@ -105,6 +118,49 @@ func (h *Handler) VoidManualDisbursement(c *gin.Context) { response.OK(c, item) } +func (h *Handler) CreateOperatingExpense(c *gin.Context) { + adminID, ok := financeAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req CreateOperatingExpenseRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "运营开支信息不正确") + return + } + item, err := h.service.CreateOperatingExpense(c.Request.Context(), req, adminID, financeAuditMeta(c)) + if err != nil { + writeFinanceError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) VoidOperatingExpense(c *gin.Context) { + adminID, ok := financeAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "运营开支记录 ID 不正确") + return + } + var req VoidOperatingExpenseRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请填写作废原因") + return + } + item, err := h.service.VoidOperatingExpense(c.Request.Context(), id, req.Reason, adminID, financeAuditMeta(c)) + if err != nil { + writeFinanceError(c, err) + return + } + response.OK(c, item) +} + func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) { start, end, ok := parseDateRange(c, 6) if !ok { @@ -196,6 +252,34 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) { return query, true } +func parseOperatingExpenseQuery(c *gin.Context) (OperatingExpenseQuery, bool) { + start, end, ok := parseDateRange(c, 29) + if !ok { + return OperatingExpenseQuery{}, false + } + query := OperatingExpenseQuery{ + Status: strings.TrimSpace(c.Query("status")), + Category: strings.TrimSpace(c.Query("category")), + Keyword: strings.TrimSpace(c.Query("keyword")), + DateType: c.DefaultQuery("date_type", "created"), + StartDate: start, + EndDate: end, + } + if query.Status != "" && query.Status != "paid" && query.Status != "voided" { + response.BadRequest(c, "运营开支状态不正确") + return query, false + } + if query.DateType != "created" && query.DateType != "occurred" { + response.BadRequest(c, "日期类型不正确") + return query, false + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + query.Page = page + query.PageSize = pageSize + return query, true +} + func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) { loc := timeutil.ShanghaiLocation() today := timeutil.ShanghaiNow() @@ -238,6 +322,12 @@ func writeFinanceError(c *gin.Context, err error) { response.NotFound(c, "线下出款记录不存在") case errors.Is(err, ErrManualDisbursementNotPaid): response.BadRequest(c, "该线下出款记录已作废") + case errors.Is(err, ErrInvalidOperatingExpense): + response.BadRequest(c, "运营开支信息不正确") + case errors.Is(err, ErrOperatingExpenseNotFound): + response.NotFound(c, "运营开支记录不存在") + case errors.Is(err, ErrOperatingExpenseNotPaid): + response.BadRequest(c, "该运营开支记录已作废") default: response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用") } diff --git a/backend/internal/modules/adminfinance/operating_expense.go b/backend/internal/modules/adminfinance/operating_expense.go new file mode 100644 index 0000000..41e196c --- /dev/null +++ b/backend/internal/modules/adminfinance/operating_expense.go @@ -0,0 +1,234 @@ +package adminfinance + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/timeutil" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type operatingExpenseRecord struct { + ID uint64 + ExpenseNo string + Category string + PayeeName string + AmountCent int64 + OccurredAt time.Time + Remark string + VoucherURL string + Status string + CreatedBy uint64 + VoidedBy *uint64 + VoidedAt *time.Time + VoidReason string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (operatingExpenseRecord) TableName() string { + return "operating_expenses" +} + +func (r *Repository) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) { + expenseNo, err := newOperatingExpenseNo() + if err != nil { + return nil, err + } + record := operatingExpenseRecord{ + ExpenseNo: expenseNo, + Category: req.Category, + PayeeName: req.PayeeName, + AmountCent: req.AmountCent, + OccurredAt: req.OccurredAt, + Remark: req.Remark, + VoucherURL: req.VoucherURL, + Status: "paid", + CreatedBy: adminID, + } + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&record).Error; err != nil { + return err + } + id := record.ID + return auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "operating_expense_create", + BizType: "operating_expense", + BizID: &id, + Meta: meta, + Detail: map[string]any{ + "expense_no": expenseNo, + "category": req.Category, + "payee_name": req.PayeeName, + "amount_cent": req.AmountCent, + "occurred_at": req.OccurredAt, + "has_voucher": req.VoucherURL != "", + }, + }) + }) + if err != nil { + return nil, err + } + return r.findOperatingExpense(ctx, record.ID) +} + +func (r *Repository) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var record operatingExpenseRecord + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrOperatingExpenseNotFound + } + return err + } + if record.Status != "paid" { + return ErrOperatingExpenseNotPaid + } + now := timeutil.ShanghaiNow() + if err := tx.Model(&record).Updates(map[string]any{ + "status": "voided", + "voided_by": adminID, + "voided_at": now, + "void_reason": reason, + "updated_at": now, + }).Error; err != nil { + return err + } + bizID := record.ID + return auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "operating_expense_void", + BizType: "operating_expense", + BizID: &bizID, + Meta: meta, + Detail: map[string]any{ + "expense_no": record.ExpenseNo, + "amount_cent": record.AmountCent, + "reason": reason, + }, + }) + }) + if err != nil { + return nil, err + } + return r.findOperatingExpense(ctx, id) +} + +func (r *Repository) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) { + var summary OperatingExpenseListSummaryDTO + if err := r.applyOperatingExpenseFilters(r.db.WithContext(ctx).Table("operating_expenses AS oe"), query). + Select(`COUNT(*) AS record_count, + COALESCE(SUM(CASE WHEN status = 'paid' THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent, + COALESCE(SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END), 0) AS paid_count`). + Scan(&summary).Error; err != nil { + return nil, err + } + + rows := make([]operatingExpenseDetailRow, 0, query.PageSize) + orderColumn := "oe.created_at" + if query.DateType == "occurred" { + orderColumn = "oe.occurred_at" + } + if err := r.applyOperatingExpenseFilters(r.operatingExpenseDetailQuery(ctx), query). + Order(orderColumn + " DESC"). + Order("oe.id DESC"). + Offset((query.Page - 1) * query.PageSize). + Limit(query.PageSize). + Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]OperatingExpenseDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return &OperatingExpenseListDTO{Items: items, Total: summary.RecordCount, Page: query.Page, PageSize: query.PageSize, Summary: summary}, nil +} + +func (r *Repository) findOperatingExpense(ctx context.Context, id uint64) (*OperatingExpenseDTO, error) { + var row operatingExpenseDetailRow + err := r.operatingExpenseDetailQuery(ctx).Where("oe.id = ?", id).Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrOperatingExpenseNotFound + } + if err != nil { + return nil, err + } + item := row.toDTO() + return &item, nil +} + +func (r *Repository) operatingExpenseDetailQuery(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx).Table("operating_expenses AS oe"). + Select(`oe.*, COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS created_by_name, + COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name`). + Joins("LEFT JOIN admin_users AS creator ON creator.id = oe.created_by"). + Joins("LEFT JOIN admin_users AS voider ON voider.id = oe.voided_by") +} + +func (r *Repository) applyOperatingExpenseFilters(db *gorm.DB, query OperatingExpenseQuery) *gorm.DB { + if query.Status != "" { + db = db.Where("oe.status = ?", query.Status) + } + if query.Category != "" { + db = db.Where("oe.category = ?", query.Category) + } + if query.Keyword != "" { + like := "%" + query.Keyword + "%" + db = db.Where("oe.expense_no LIKE ? OR oe.payee_name LIKE ? OR oe.remark LIKE ?", like, like, like) + } + if !query.StartDate.IsZero() && !query.EndDate.IsZero() { + column := "oe.created_at" + if query.DateType == "occurred" { + column = "oe.occurred_at" + } + db = db.Where(column+" >= ? AND "+column+" <= ?", query.StartDate, query.EndDate) + } + return db +} + +type operatingExpenseDetailRow struct { + ID uint64 + ExpenseNo string + Category string + PayeeName string + AmountCent int64 + OccurredAt time.Time + Remark string + VoucherURL string + Status string + CreatedBy uint64 + CreatedByName string + VoidedBy *uint64 + VoidedByName string + VoidedAt *time.Time + VoidReason string + CreatedAt time.Time +} + +func (r operatingExpenseDetailRow) toDTO() OperatingExpenseDTO { + return OperatingExpenseDTO{ + ID: r.ID, ExpenseNo: r.ExpenseNo, Category: r.Category, PayeeName: r.PayeeName, + AmountCent: r.AmountCent, OccurredAt: r.OccurredAt, Remark: r.Remark, VoucherURL: r.VoucherURL, + Status: r.Status, CreatedBy: r.CreatedBy, CreatedByName: r.CreatedByName, VoidedBy: r.VoidedBy, + VoidedByName: r.VoidedByName, VoidedAt: r.VoidedAt, VoidReason: r.VoidReason, CreatedAt: r.CreatedAt, + } +} + +func newOperatingExpenseNo() (string, error) { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return fmt.Sprintf("OE%s%s", timeutil.ShanghaiNow().Format("20060102150405"), strings.ToUpper(hex.EncodeToString(buf))), nil +} diff --git a/backend/internal/modules/adminfinance/operating_expense_test.go b/backend/internal/modules/adminfinance/operating_expense_test.go new file mode 100644 index 0000000..9481048 --- /dev/null +++ b/backend/internal/modules/adminfinance/operating_expense_test.go @@ -0,0 +1,117 @@ +package adminfinance + +import ( + "errors" + "testing" + "time" + + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/timeutil" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestOperatingExpenseCreateListAndVoid(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + for _, statement := range []string{ + `CREATE TABLE operating_expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, expense_no TEXT NOT NULL UNIQUE, category TEXT NOT NULL, + payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, occurred_at DATETIME NOT NULL, + remark TEXT NOT NULL, voucher_url TEXT NOT NULL, status TEXT NOT NULL, created_by INTEGER NOT NULL, + voided_by INTEGER, voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME, updated_at DATETIME + )`, + `CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`, + `CREATE TABLE audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, actor_type TEXT, actor_id INTEGER, action TEXT, biz_type TEXT, + biz_id INTEGER, ip TEXT, user_agent TEXT, detail BLOB, created_at DATETIME + )`, + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("创建测试表失败: %v", err) + } + } + if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES (7, '运营甲', 'ops_a'), (8, '运营乙', 'ops_b')`).Error; err != nil { + t.Fatal(err) + } + + repo := NewRepository(db) + occurredAt := time.Date(2026, 8, 20, 10, 30, 0, 0, timeutil.ShanghaiLocation()) + created, err := repo.CreateOperatingExpense(t.Context(), CreateOperatingExpenseRequest{ + Category: "推广投放", + PayeeName: "测试媒体", + AmountCent: 12800, + OccurredAt: occurredAt, + Remark: "八月推广费用", + VoucherURL: "/api/files/object?key=operating-expense%2Fvoucher.webp", + }, 7, auditlog.Meta{RequestID: "req-create"}) + if err != nil { + t.Fatalf("创建运营开支失败: %v", err) + } + if created.ID == 0 || created.ExpenseNo == "" || created.Status != "paid" || created.CreatedByName != "运营甲" { + t.Fatalf("创建结果不正确: %+v", created) + } + + list, err := repo.OperatingExpenses(t.Context(), OperatingExpenseQuery{ + DateType: "occurred", StartDate: occurredAt.Add(-time.Hour), EndDate: occurredAt.Add(time.Hour), Page: 1, PageSize: 20, + }) + if err != nil { + t.Fatalf("查询运营开支失败: %v", err) + } + if list.Total != 1 || list.Summary.PaidAmountCent != 12800 || list.Items[0].Category != "推广投放" { + t.Fatalf("运营开支列表不正确: %+v", list) + } + + voided, err := repo.VoidOperatingExpense(t.Context(), created.ID, "重复录入", 8, auditlog.Meta{RequestID: "req-void"}) + if err != nil { + t.Fatalf("作废运营开支失败: %v", err) + } + if voided.Status != "voided" || voided.VoidedByName != "运营乙" || voided.VoidReason != "重复录入" { + t.Fatalf("作废结果不正确: %+v", voided) + } + if _, err := repo.VoidOperatingExpense(t.Context(), created.ID, "重复作废", 8, auditlog.Meta{}); !errors.Is(err, ErrOperatingExpenseNotPaid) { + t.Fatalf("重复作废错误 = %v, want ErrOperatingExpenseNotPaid", err) + } +} + +func TestOperatingExpenseDashboardSummaryExcludesVoided(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.Exec(`CREATE TABLE operating_expenses ( + id INTEGER PRIMARY KEY, amount_cent INTEGER NOT NULL, occurred_at DATETIME NOT NULL, status TEXT NOT NULL + )`).Error; err != nil { + t.Fatalf("创建运营开支表失败: %v", err) + } + loc := timeutil.ShanghaiLocation() + inRange := time.Date(2026, 8, 20, 10, 0, 0, 0, loc) + if err := db.Exec(`INSERT INTO operating_expenses (id, amount_cent, occurred_at, status) VALUES + (1, 12000, ?, 'paid'), (2, 8000, ?, 'voided'), (3, 5000, ?, 'paid')`, inRange, inRange, inRange.AddDate(0, 0, -1)).Error; err != nil { + t.Fatal(err) + } + summary, err := NewRepository(db).operatingExpenseSummary(t.Context(), DashboardQuery{ + StartDate: time.Date(2026, 8, 20, 0, 0, 0, 0, loc), + EndDate: time.Date(2026, 8, 20, 23, 59, 59, 0, loc), + }) + if err != nil { + t.Fatalf("运营开支仪表盘统计失败: %v", err) + } + if summary.AmountCent != 12000 || summary.Count != 1 { + t.Fatalf("运营开支统计 = %+v, want 12000/1", summary) + } + daily, err := NewRepository(db).dailyOperatingExpenses(t.Context(), DashboardQuery{ + StartDate: time.Date(2026, 8, 20, 0, 0, 0, 0, loc), + EndDate: time.Date(2026, 8, 20, 23, 59, 59, 0, loc), + }) + if err != nil { + t.Fatalf("每日运营开支统计失败: %v", err) + } + if len(daily) != 1 || daily[0].Date != "2026-08-20" || daily[0].AmountCent != 12000 || daily[0].Count != 1 { + t.Fatalf("每日运营开支统计 = %+v, want 2026-08-20/12000/1", daily) + } +} diff --git a/backend/internal/modules/adminfinance/service.go b/backend/internal/modules/adminfinance/service.go index cbd5f48..b54179d 100644 --- a/backend/internal/modules/adminfinance/service.go +++ b/backend/internal/modules/adminfinance/service.go @@ -15,6 +15,9 @@ var ( ErrInvalidManualDisbursement = errors.New("invalid manual disbursement") ErrManualDisbursementNotFound = errors.New("manual disbursement not found") ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid") + ErrInvalidOperatingExpense = errors.New("invalid operating expense") + ErrOperatingExpenseNotFound = errors.New("operating expense not found") + ErrOperatingExpenseNotPaid = errors.New("operating expense is not paid") ) type Service struct { @@ -64,6 +67,22 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (* return s.repo.Disbursements(ctx, query) } +func (s *Service) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + return s.repo.OperatingExpenses(ctx, query) +} + func (s *Service) CreateManualDisbursement( ctx context.Context, req CreateManualDisbursementRequest, @@ -106,6 +125,35 @@ func (s *Service) VoidManualDisbursement( return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta) } +func (s *Service) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + req.Category = strings.TrimSpace(req.Category) + req.PayeeName = strings.TrimSpace(req.PayeeName) + req.Remark = strings.TrimSpace(req.Remark) + req.VoucherURL = strings.TrimSpace(req.VoucherURL) + if utf8.RuneCountInString(req.Category) < 1 || utf8.RuneCountInString(req.Category) > 50 || + utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 || + req.AmountCent <= 0 || req.OccurredAt.IsZero() || + utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 || + !validManualVoucherURL(req.VoucherURL) { + return nil, ErrInvalidOperatingExpense + } + return s.repo.CreateOperatingExpense(ctx, req, adminID, meta) +} + +func (s *Service) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + reason = strings.TrimSpace(reason) + if id == 0 || utf8.RuneCountInString(reason) < 1 || utf8.RuneCountInString(reason) > 255 { + return nil, ErrInvalidOperatingExpense + } + return s.repo.VoidOperatingExpense(ctx, id, reason, adminID, meta) +} + func validManualDisbursementCategory(category string) bool { switch category { case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom": diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a6914a5..3701623 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -680,6 +680,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/finance/disbursements", requirePerm("wallet:view"), adminFinanceHandler.Disbursements) adminRoutes.POST("/finance/manual-disbursements", requirePerm("finance:manual_disbursement"), adminFinanceHandler.CreateManualDisbursement) adminRoutes.POST("/finance/manual-disbursements/:id/void", requirePerm("finance:manual_disbursement"), adminFinanceHandler.VoidManualDisbursement) + adminRoutes.GET("/finance/operating-expenses", requirePerm("finance:operating_expense"), adminFinanceHandler.OperatingExpenses) + adminRoutes.POST("/finance/operating-expenses", requirePerm("finance:operating_expense"), adminFinanceHandler.CreateOperatingExpense) + adminRoutes.POST("/finance/operating-expenses/:id/void", requirePerm("finance:operating_expense"), adminFinanceHandler.VoidOperatingExpense) adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger) adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList) diff --git a/backend/migrations/000054_operating_expenses.sql b/backend/migrations/000054_operating_expenses.sql new file mode 100644 index 0000000..9cd2d48 --- /dev/null +++ b/backend/migrations/000054_operating_expenses.sql @@ -0,0 +1,44 @@ +-- +goose Up + +CREATE TABLE operating_expenses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + expense_no VARCHAR(64) NOT NULL COMMENT '运营开支单号', + category VARCHAR(50) NOT NULL COMMENT '开支类别', + payee_name VARCHAR(100) NOT NULL COMMENT '收款方或单位名称', + amount_cent BIGINT NOT NULL COMMENT '开支金额(分)', + occurred_at DATETIME NOT NULL COMMENT '实际开支时间', + remark VARCHAR(500) NOT NULL COMMENT '开支用途备注', + voucher_url VARCHAR(500) NOT NULL DEFAULT '' COMMENT '开支凭证地址', + status VARCHAR(16) NOT NULL DEFAULT 'paid' COMMENT '状态: paid已记账/voided已作废', + created_by BIGINT UNSIGNED NOT NULL COMMENT '录入管理员ID', + voided_by BIGINT UNSIGNED NULL COMMENT '作废管理员ID', + voided_at DATETIME NULL COMMENT '作废时间', + void_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '作废原因', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_operating_expenses_no (expense_no), + KEY idx_operating_expenses_occurred (status, occurred_at), + KEY idx_operating_expenses_created (created_at), + KEY idx_operating_expenses_category (category) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='运营开支记账'; + +INSERT INTO permissions (code, name, resource, action) VALUES +('finance:operating_expense', '管理运营开支', 'finance', 'operating_expense') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code IN ('super_admin', 'finance') AND p.code = 'finance:operating_expense'; + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'finance:operating_expense'; + +DELETE FROM permissions WHERE code = 'finance:operating_expense'; +DROP TABLE IF EXISTS operating_expenses; diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index af0ed3c..ea00a22 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -68,6 +68,8 @@ export interface FinanceDailyItem { withdrawal_paid_count: number manual_paid_amount_cent: number manual_paid_count: number + operating_expense_amount_cent: number + operating_expense_count: number disbursement_paid_amount_cent: number disbursement_paid_count: number } @@ -107,6 +109,11 @@ export interface FinanceDisbursementSummary { manual_paid_count: number } +export interface OperatingExpenseSummary { + amount_cent: number + count: number +} + export interface FinanceDisbursementListSummary { record_count: number paid_amount_cent: number @@ -207,9 +214,61 @@ export interface FinanceDashboard { pickup_summary: FinancePickupSummary mohong_summary: FinanceMohongSummary disbursement_summary: FinanceDisbursementSummary + operating_expense_summary: OperatingExpenseSummary generated_at: string } +export interface OperatingExpense { + id: number + expense_no: string + category: string + payee_name: string + amount_cent: number + occurred_at: string + remark: string + voucher_url: string + status: 'paid' | 'voided' + created_by: number + created_by_name: string + voided_by?: number + voided_by_name: string + voided_at?: string + void_reason: string + created_at: string +} + +export interface OperatingExpenseQuery extends FinanceDateQuery { + status?: string + category?: string + keyword?: string + date_type?: 'created' | 'occurred' + page?: number + page_size?: number +} + +export interface OperatingExpenseListSummary { + record_count: number + paid_amount_cent: number + paid_count: number +} + +export interface OperatingExpenseList { + items: OperatingExpense[] + total: number + page: number + page_size: number + summary: OperatingExpenseListSummary +} + +export interface CreateOperatingExpensePayload { + category: string + payee_name: string + amount_cent: number + occurred_at: string + remark: string + voucher_url?: string +} + export interface FinanceDetail { order_id: number order_no: string @@ -309,6 +368,10 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) { manual_paid_amount_cent: 0, manual_paid_count: 0, }, + operating_expense_summary: data.data?.operating_expense_summary ?? { + amount_cent: 0, + count: 0, + }, } } @@ -350,6 +413,37 @@ export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery } } +export async function fetchOperatingExpenses(query: OperatingExpenseQuery = {}) { + const { data } = await apiClient.get>( + '/admin/finance/operating-expenses', + { params: cleanParams(query) } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + page: Number(result?.page ?? query.page ?? 1), + page_size: Number(result?.page_size ?? query.page_size ?? 20), + summary: result?.summary ?? { record_count: 0, paid_amount_cent: 0, paid_count: 0 }, + } +} + +export async function createOperatingExpense(payload: CreateOperatingExpensePayload) { + const { data } = await apiClient.post>( + '/admin/finance/operating-expenses', + payload + ) + return data.data +} + +export async function voidOperatingExpense(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/finance/operating-expenses/${id}/void`, + { reason } + ) + return data.data +} + export async function createManualDisbursement(payload: CreateManualDisbursementPayload) { const { data } = await apiClient.post>( '/admin/finance/manual-disbursements', diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 5838d95..0872599 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -204,6 +204,11 @@ function diffType(value: number) { {{ moneyCent(dashboard.disbursement_summary.manual_paid_amount_cent) }} {{ dashboard.disbursement_summary.manual_paid_count }} 笔有效记账 +
+ 运营开支 + {{ moneyCent(dashboard.operating_expense_summary.amount_cent) }} + {{ dashboard.operating_expense_summary.count }} 笔有效记账 +
当前待出款 {{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }} @@ -286,6 +291,9 @@ function diffType(value: number) { + + +

@@ -331,6 +339,10 @@ function diffType(value: number) { border-left: 3px solid #0f766e; } +.operating-expense-card { + border-left: 3px solid #b45309; +} + .pending-card { border-left: 3px solid #d97706; } diff --git a/frontend/src/features/admin/views/AdminOperatingExpensesView.vue b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue new file mode 100644 index 0000000..c6325f3 --- /dev/null +++ b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 08e7a4a..9c5c1cd 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -169,6 +169,12 @@ const allNavGroups: NavGroup[] = [ icon: Money, permission: 'wallet:view', }, + { + label: '运营开支', + to: adminPath('finance/operating-expenses'), + icon: Money, + permission: 'finance:operating_expense', + }, { label: '资金流水', to: adminPath('wallet-ledger'), diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 0d3b7d1..3df265a 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -138,6 +138,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminFinanceDisbursementsView.vue'), meta: adminMeta, }, + { + path: adminPath('finance/operating-expenses'), + name: 'admin-finance-operating-expenses', + component: () => import('@/features/admin/views/AdminOperatingExpensesView.vue'), + meta: adminMeta, + }, { path: adminPath('wallet-ledger'), name: 'admin-wallet-ledger', From 9a52b51c89086cf4176a2bc9d754ec7aa12caea9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 20 Aug 2026 16:10:55 +0800 Subject: [PATCH 23/70] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E4=BB=AA=E8=A1=A8=E7=9B=98UI=E4=B8=8E=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/admin/api/backup.ts | 5 +- .../admin/views/AdminDisputesView.vue | 4 +- .../admin/views/AdminFinanceDashboardView.vue | 1660 +++++++++++++---- .../admin/views/AdminListingReviewView.vue | 2 +- .../admin/views/AdminListingsView.vue | 5 +- .../admin/views/AdminMohongCategoriesView.vue | 8 +- .../admin/views/AdminMohongConfigView.vue | 9 +- .../admin/views/AdminMohongOrdersView.vue | 20 +- .../admin/views/AdminMohongProductsView.vue | 35 +- .../views/AdminOperatingExpensesView.vue | 161 +- .../listings/components/HomeFilters.vue | 13 +- frontend/src/features/mohong/api/mohong.ts | 9 +- .../mohong/components/MohongCartPanel.vue | 12 +- .../mohong/views/MobileMohongListView.vue | 11 +- .../views/MobileMohongOrderDetailView.vue | 9 +- .../features/mohong/views/MohongListView.vue | 2 - .../mohong/views/MohongOrderDetailView.vue | 9 +- .../components/OrderAccountSnapshot.vue | 6 +- .../src/features/orders/views/OrdersView.vue | 16 +- .../views/MobileSellerListingDetailView.vue | 8 +- .../seller/views/MobileSellerListingsView.vue | 8 +- .../seller/views/SellerListingDetailView.vue | 31 +- frontend/src/shared/utils/auditLabels.ts | 4 +- frontend/src/shared/utils/statusLabels.ts | 12 +- frontend/src/styles/home-filters.css | 5 +- 25 files changed, 1626 insertions(+), 438 deletions(-) diff --git a/frontend/src/features/admin/api/backup.ts b/frontend/src/features/admin/api/backup.ts index 96aa19d..073a59f 100644 --- a/frontend/src/features/admin/api/backup.ts +++ b/frontend/src/features/admin/api/backup.ts @@ -27,6 +27,9 @@ export async function fetchBackupSchedule() { } export async function updateBackupSchedule(payload: BackupSchedule) { - const { data } = await apiClient.put>('/admin/backups/schedule', payload) + const { data } = await apiClient.put>( + '/admin/backups/schedule', + payload + ) return data.data } diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index e1375e1..e279011 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -337,7 +337,9 @@ async function handleArbitrate() { result: result.value, remark: remark.value.trim(), amount_cent: - isCompletedResult.value && amount.value !== undefined ? yuanToCent(amount.value) : undefined, + isCompletedResult.value && amount.value !== undefined + ? yuanToCent(amount.value) + : undefined, actual_coin_consumed_m: isCompletedResult.value ? actualCoinConsumedM.value : undefined, actual_consumable_amount_cent: isCompletedResult.value ? yuanToCent(actualConsumableAmountYuan.value) diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 0872599..704d51f 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -1,5 +1,19 @@ diff --git a/frontend/src/features/admin/views/AdminListingReviewView.vue b/frontend/src/features/admin/views/AdminListingReviewView.vue index 970dfde..6eed773 100644 --- a/frontend/src/features/admin/views/AdminListingReviewView.vue +++ b/frontend/src/features/admin/views/AdminListingReviewView.vue @@ -36,7 +36,7 @@ interface RiskItem { level: RiskLevel } -const defaultScreenshotURL = '/api/listings/default-upload-screenshot?v=webp1' +const _defaultScreenshotURL = '/api/listings/default-upload-screenshot?v=webp1' const rejectReasonOptions = [ '默认截图,需补充真实截图', '账号资产信息不完整', diff --git a/frontend/src/features/admin/views/AdminListingsView.vue b/frontend/src/features/admin/views/AdminListingsView.vue index baf028a..964c40f 100644 --- a/frontend/src/features/admin/views/AdminListingsView.vue +++ b/frontend/src/features/admin/views/AdminListingsView.vue @@ -209,10 +209,7 @@ function restoreListState() { if (typeof state.page === 'number' && state.page > 0) { currentPage.value = state.page } - if ( - typeof state.pageSize === 'number' && - [10, 20, 50, 100].includes(state.pageSize) - ) { + if (typeof state.pageSize === 'number' && [10, 20, 50, 100].includes(state.pageSize)) { pageSize.value = state.pageSize } } catch { diff --git a/frontend/src/features/admin/views/AdminMohongCategoriesView.vue b/frontend/src/features/admin/views/AdminMohongCategoriesView.vue index 6374f52..6c623b9 100644 --- a/frontend/src/features/admin/views/AdminMohongCategoriesView.vue +++ b/frontend/src/features/admin/views/AdminMohongCategoriesView.vue @@ -78,11 +78,9 @@ async function handleSave() { async function handleDelete(item: MohongCategory) { try { - await ElMessageBox.confirm( - `确认删除分类「${item.name}」?其下商品将变为未分类。`, - '提示', - { type: 'warning' } - ) + await ElMessageBox.confirm(`确认删除分类「${item.name}」?其下商品将变为未分类。`, '提示', { + type: 'warning', + }) await deleteAdminMohongCategory(item.id) ElMessage.success('已删除') await load() diff --git a/frontend/src/features/admin/views/AdminMohongConfigView.vue b/frontend/src/features/admin/views/AdminMohongConfigView.vue index bb62558..47023e6 100644 --- a/frontend/src/features/admin/views/AdminMohongConfigView.vue +++ b/frontend/src/features/admin/views/AdminMohongConfigView.vue @@ -64,7 +64,9 @@ async function uploadQrcode(file: File) {

撞车配置

-

全局默认固定二维码与订单复制模板。商品可覆盖默认二维码;支付后用户在订单详情扫码并复制信息联系客服。

+

+ 全局默认固定二维码与订单复制模板。商品可覆盖默认二维码;支付后用户在订单详情扫码并复制信息联系客服。 +

保存配置
@@ -91,8 +93,9 @@ async function uploadQrcode(file: File) {
可用变量:{{order_no}} {{created_at}} {{items}}(多商品明细) {{product_title}} - {{quantity}} {{amount}} {{buyer_name}} - {{buyer_phone}} {{unit}} + {{quantity}} {{amount}} + {{buyer_name}} {{buyer_phone}} + {{unit}}
diff --git a/frontend/src/features/admin/views/AdminMohongOrdersView.vue b/frontend/src/features/admin/views/AdminMohongOrdersView.vue index 5f33122..5d39446 100644 --- a/frontend/src/features/admin/views/AdminMohongOrdersView.vue +++ b/frontend/src/features/admin/views/AdminMohongOrdersView.vue @@ -128,9 +128,7 @@ function canComplete(row: MohongOrder) { } function canCancel(row: MohongOrder) { - return ( - row.status === 'pending_payment' || row.status === 'paid' || row.status === 'receiving' - ) + return row.status === 'pending_payment' || row.status === 'paid' || row.status === 'receiving' } function canRefund(row: MohongOrder) { @@ -158,7 +156,13 @@ async function copyText(text: string) {
- + @@ -167,7 +171,7 @@ async function copyText(text: string) { - 查询 + 查询
@@ -262,7 +266,11 @@ async function copyText(text: string) {
二维码快照 - +
diff --git a/frontend/src/features/admin/views/AdminMohongProductsView.vue b/frontend/src/features/admin/views/AdminMohongProductsView.vue index d912471..7b9fb92 100644 --- a/frontend/src/features/admin/views/AdminMohongProductsView.vue +++ b/frontend/src/features/admin/views/AdminMohongProductsView.vue @@ -207,7 +207,13 @@ function removeGallery(url: string) {
- + @@ -216,7 +222,7 @@ function removeGallery(url: string) { - 查询 + 查询
@@ -273,7 +279,12 @@ function removeGallery(url: string) { > - + @@ -288,7 +299,11 @@ function removeGallery(url: string) { style="width: 80px; height: 80px; border-radius: 8px" fit="cover" /> - + 上传封面 @@ -299,7 +314,11 @@ function removeGallery(url: string) { - + 添加图片 @@ -331,7 +350,11 @@ function removeGallery(url: string) { - +
- + - + @@ -249,7 +259,12 @@ function statusType(status: string) { - + @@ -264,13 +279,17 @@ function statusType(status: string) { - + - + @@ -280,7 +299,9 @@ function statusType(status: string) { diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 2388f15..34c5e0d 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -46,8 +46,8 @@ export interface Order { growth_points_awarded?: number growth_points_awarded_at?: string account_snapshot?: Record - account_source?: 'internal' | 'external' | string - source_channel?: string + account_source?: 'internal' | 'external' | string + source_channel?: string listing_snapshot?: string checkout_info?: string counter_info?: string @@ -152,24 +152,24 @@ export interface HandoffRecord { } export interface ProcessEvent { - id: number - business_type: string - business_id: number - stage: string - action: string - actor_type: 'user' | 'admin' | 'system' | string - actor_id: number - actor_name: string - target_type: string - target_id?: number - target_name: string - content: string - reason: string - payload: Record - attachment_urls: string[] - state_before: Record - state_after: Record - created_at: string + id: number + business_type: string + business_id: number + stage: string + action: string + actor_type: 'user' | 'admin' | 'system' | string + actor_id: number + actor_name: string + target_type: string + target_id?: number + target_name: string + content: string + reason: string + payload: Record + attachment_urls: string[] + state_before: Record + state_after: Record + created_at: string } export interface PaymentOrder { @@ -312,10 +312,10 @@ export async function fetchHandoffRecords(id: string | number) { } export async function fetchAdminOrderProcessEvents(id: string | number) { - const { data } = await apiClient.get>( - `/admin/orders/${id}/process-events` - ) - return Array.isArray(data.data?.items) ? data.data.items : [] + const { data } = await apiClient.get>( + `/admin/orders/${id}/process-events` + ) + return Array.isArray(data.data?.items) ? data.data.items : [] } export async function confirmReceive(id: number) { From 527e5ed228145a522b667550b5b6f95e65ab6a5f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 29 Aug 2026 00:55:55 +0800 Subject: [PATCH 46/70] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E4=BB=AA=E8=A1=A8=E7=9B=98=E5=8A=A0=E8=BD=BD=E4=B8=8E=E5=88=A9?= =?UTF-8?q?=E6=B6=A6=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/adminfinance/dashboard.go | 122 +++++++++++------- .../modules/adminfinance/dashboard_test.go | 84 ++++++++++++ .../internal/modules/adminfinance/subquery.go | 41 ++++++ .../000060_finance_dashboard_indexes.sql | 58 +++++++++ .../admin/views/AdminFinanceDashboardView.vue | 6 +- 5 files changed, 261 insertions(+), 50 deletions(-) create mode 100644 backend/migrations/000060_finance_dashboard_indexes.sql diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 4edaf5c..0bf89d4 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -2,6 +2,7 @@ package adminfinance import ( "context" + "sync" "hfb_sys/backend/internal/timeutil" ) @@ -10,30 +11,63 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash if cached := r.loadDashboardCache(ctx, query); cached != nil { return cached, nil } - dailyItems, err := r.dailyItems(ctx, query) - if err != nil { - return nil, err + // 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL, + // 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。 + var ( + dailyItems []FinanceDailyDTO + pickup *PickupSummaryDTO + mohong *MohongSummaryDTO + disbursement *DisbursementSummaryDTO + operatingExpense *OperatingExpenseSummaryDTO + ) + var wg sync.WaitGroup + errCh := make(chan error, 5) + run := func(fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + if err := fn(); err != nil { + errCh <- err + } + }() } - pickup, err := r.pickupSummary(ctx, query) - if err != nil { - return nil, err + run(func() error { + var err error + dailyItems, err = r.dailyItems(ctx, query) + return err + }) + run(func() error { + var err error + pickup, err = r.pickupSummary(ctx, query) + return err + }) + run(func() error { + var err error + mohong, err = r.mohongSummary(ctx, query) + return err + }) + run(func() error { + var err error + disbursement, err = r.disbursementSummary(ctx, query) + return err + }) + run(func() error { + var err error + operatingExpense, err = r.operatingExpenseSummary(ctx, query) + return err + }) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return nil, err + } } + summary, err := r.summary(ctx, query, pickup) if err != nil { return nil, err } - mohong, err := r.mohongSummary(ctx, query) - if err != nil { - return nil, err - } - disbursement, err := r.disbursementSummary(ctx, query) - if err != nil { - return nil, err - } - operatingExpense, err := r.operatingExpenseSummary(ctx, query) - if err != nil { - return nil, err - } result := &DashboardDTO{ Summary: *summary, DailyItems: dailyItems, @@ -126,6 +160,7 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (* func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) { db := r.db.WithContext(ctx) var payment paymentSummaryRow + originalPayments := paymentOriginalAmountForRefundsInRangeSubquery(db, query) if err := db.Table("payment_orders AS po"). Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, @@ -135,12 +170,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup * COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`, payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). - Joins(`LEFT JOIN ( - SELECT order_id, MAX(amount_cent) AS amount_cent - FROM payment_orders - WHERE biz_type IN ? AND status = 'paid' - GROUP BY order_id - ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). + Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", originalPayments). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Scan(&payment).Error; err != nil { return nil, err @@ -164,32 +194,31 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup * THEN COALESCE(ro.offline_settlement_amount_cent, 0) ELSE 0 END), 0) AS offline_settlement_pending_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 THEN 1 ELSE 0 END), 0) AS offline_settlement_pending_count, - COUNT(ro.id) AS settled_order_count`). + COALESCE(SUM(CASE WHEN COALESCE(p.failed_refund_amount_cent, 0) > 0 + OR COALESCE(p.refunding_amount_cent, 0) > 0 + OR ro.refund_status = 'refunding' + OR ABS(COALESCE(oc.owner_income_amount_cent, 0) - CASE WHEN ro.settlement_mode = 'platform_managed' + THEN COALESCE(ro.offline_settlement_amount_cent, 0) + ELSE COALESCE(w.owner_wallet_income_amount_cent, 0) + END) >= ? + THEN 1 + ELSE 0 + END), 0) AS financial_exception_count, + COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent). Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). - Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)). + Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForSettledOrdersSubquery(db, query)). Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). Scan(&settlement).Error; err != nil { return nil, err } - var exceptionCount int64 - if err := db.Table("(?) AS d", r.financeDetailBaseQuery(ctx, DetailQuery{ - DateType: "settled", - StartDate: query.StartDate, - EndDate: query.EndDate, - })). - Where("finance_status <> ?", financeStatusNormal). - Where("finance_status <> ?", financeStatusOfflineSettlementPending). - Count(&exceptionCount).Error; err != nil { - return nil, err - } - // 预计收入:尚未结算订单的下单预估平台手续费之和,按下单时间落在查询区间内统计。 var estimated estimatedIncomeRow if err := db.Table("rental_orders"). @@ -232,7 +261,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup * SettledOrderCount: settlement.SettledOrderCount, PendingSettleOrderCount: estimated.PendingSettleOrderCount, OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount, - FinancialExceptionCount: exceptionCount, + FinancialExceptionCount: settlement.FinancialExceptionCount, }, nil } @@ -274,7 +303,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi END), 0) AS offline_settlement_pending_count, COUNT(ro.id) AS settled_order_count`). Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). - Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)). Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). Group("DATE(ro.settled_at)"). Scan(&settlements).Error; err != nil { @@ -439,7 +468,8 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi // normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。 func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) { rows := make([]dailyPaymentRow, 0) - err := r.db.WithContext(ctx).Table("payment_orders AS po"). + db := r.db.WithContext(ctx) + err := db.Table("payment_orders AS po"). Select(`DATE(po.created_at) AS date, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, @@ -450,12 +480,7 @@ func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([ COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS normal_full_refund_count`, payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). - Joins(`LEFT JOIN ( - SELECT order_id, MAX(amount_cent) AS amount_cent - FROM payment_orders - WHERE biz_type IN ? AND status = 'paid' - GROUP BY order_id - ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). + Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Group("DATE(po.created_at)"). Scan(&rows).Error @@ -512,6 +537,7 @@ type settlementSummaryRow struct { OfflineSettlementAmountCent int64 OfflineSettlementPendingAmountCent int64 OfflineSettlementPendingCount int64 + FinancialExceptionCount int64 SettledOrderCount int64 } diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go index 4394252..754b040 100644 --- a/backend/internal/modules/adminfinance/dashboard_test.go +++ b/backend/internal/modules/adminfinance/dashboard_test.go @@ -240,3 +240,87 @@ func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) { t.Fatalf("调整日汇总 = %+v", adjusted) } } + +func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + for _, statement := range []string{ + `CREATE TABLE payment_orders ( + id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, biz_type TEXT NOT NULL, + status TEXT NOT NULL, amount_cent INTEGER NOT NULL, created_at DATETIME NOT NULL + )`, + `CREATE TABLE rental_orders ( + id INTEGER PRIMARY KEY, settled_at DATETIME NULL, settlement_mode TEXT NOT NULL, + offline_settlement_amount_cent INTEGER NOT NULL, offline_settlement_status TEXT NOT NULL, + refund_status TEXT NOT NULL, settlement_status TEXT NOT NULL, status TEXT NOT NULL, + created_at DATETIME NOT NULL, platform_fee_cent INTEGER NOT NULL + )`, + `CREATE TABLE order_checkouts ( + id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, status TEXT NOT NULL, + platform_fee_cent INTEGER NOT NULL, owner_income_amount_cent INTEGER NOT NULL + )`, + `CREATE TABLE wallet_ledger ( + id INTEGER PRIMARY KEY, order_id INTEGER NULL, direction TEXT NOT NULL, + biz_type TEXT NOT NULL, amount_cent INTEGER NOT NULL + )`, + `CREATE TABLE admin_pickups ( + id INTEGER PRIMARY KEY, status TEXT NOT NULL, settle_amount_cent INTEGER NOT NULL, + profit_amount_cent INTEGER NOT NULL, completed_at DATETIME NULL + )`, + `CREATE TABLE admin_pickup_financial_adjustments ( + id INTEGER PRIMARY KEY, pickup_id INTEGER NOT NULL, settle_delta_cent INTEGER NOT NULL, + profit_delta_cent INTEGER NOT NULL, created_at DATETIME NOT NULL + )`, + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("创建统计测试表失败: %v", err) + } + } + + loc := timeutil.ShanghaiLocation() + settledAt := time.Date(2026, 8, 2, 10, 0, 0, 0, loc) + for _, row := range [][]any{ + {1, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 300}, + {2, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 200}, + } { + if err := db.Exec(`INSERT INTO rental_orders + (id, settled_at, settlement_mode, offline_settlement_amount_cent, offline_settlement_status, + refund_status, settlement_status, status, created_at, platform_fee_cent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil { + t.Fatalf("写入订单失败: %v", err) + } + } + for _, row := range [][]any{{1, 1, "accepted", 300, 700}, {2, 2, "accepted", 200, 600}} { + if err := db.Exec(`INSERT INTO order_checkouts + (id, order_id, status, platform_fee_cent, owner_income_amount_cent) VALUES (?, ?, ?, ?, ?)`, row...).Error; err != nil { + t.Fatalf("写入结账单失败: %v", err) + } + } + for _, row := range [][]any{{1, 1, "in", "owner_income", 700}, {2, 2, "in", "owner_income", 500}} { + if err := db.Exec(`INSERT INTO wallet_ledger (id, order_id, direction, biz_type, amount_cent) VALUES (?, ?, ?, ?, ?)`, row...).Error; err != nil { + t.Fatalf("写入钱包流水失败: %v", err) + } + } + + query := DashboardQuery{ + StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc), + EndDate: time.Date(2026, 8, 2, 23, 59, 59, 0, loc), + } + repo := NewRepository(db) + pickup, err := repo.pickupSummary(t.Context(), query) + if err != nil { + t.Fatalf("读取提号汇总失败: %v", err) + } + summary, err := repo.summary(t.Context(), query, pickup) + if err != nil { + t.Fatalf("读取财务汇总失败: %v", err) + } + if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 { + t.Fatalf("普通订单数量或利润错误: %+v", summary) + } + if summary.FinancialExceptionCount != 1 { + t.Fatalf("财务异常数 = %d, want 1", summary.FinancialExceptionCount) + } +} diff --git a/backend/internal/modules/adminfinance/subquery.go b/backend/internal/modules/adminfinance/subquery.go index 7ef386b..435c925 100644 --- a/backend/internal/modules/adminfinance/subquery.go +++ b/backend/internal/modules/adminfinance/subquery.go @@ -28,3 +28,44 @@ func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB { Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation"}). Group("order_id") } + +// ownerWalletIncomeForSettledOrdersSubquery 只为当前仪表盘区间内已结算订单汇总钱包入账。 +// 原先的全表分组会随钱包流水积累持续变慢;此处先通过 settled_at 缩小订单集合, +// 再使用 wallet_ledger.order_id 索引关联对应流水。 +func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB { + return db.Table("rental_orders AS scoped_order"). + Select(`scoped_order.id AS order_id, + COALESCE(SUM(wallet.amount_cent), 0) AS owner_wallet_income_amount_cent`). + Joins(`LEFT JOIN wallet_ledger AS wallet + ON wallet.order_id = scoped_order.id + AND wallet.direction = ? + AND wallet.biz_type IN ?`, "in", []string{"owner_income", "deposit_compensation"}). + Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate). + Group("scoped_order.id") +} + +// orderPaymentForSettledOrdersSubquery 只聚合当前结算区间订单的退款状态, +// 供仪表盘异常数判断使用,避免 financeDetailBaseQuery 在首页扫描所有历史付款单。 +func orderPaymentForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB { + return db.Table("rental_orders AS scoped_order"). + Select(`scoped_order.id AS order_id, + COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'refunding' THEN payment.amount_cent ELSE 0 END), 0) AS refunding_amount_cent, + COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'failed' THEN payment.amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, refundBizTypes(), refundBizTypes()). + Joins("LEFT JOIN payment_orders AS payment ON payment.order_id = scoped_order.id AND payment.biz_type IN ?", refundBizTypes()). + Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate). + Group("scoped_order.id") +} + +// paymentOriginalAmountForRefundsInRangeSubquery 仅为当前区间内的退款单回查原支付金额。 +// 退款统计原先会对全部历史支付单按 order_id 聚合;数据增长后这部分即使只看一天也会很慢。 +func paymentOriginalAmountForRefundsInRangeSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB { + refundOrders := db.Table("payment_orders AS refund"). + Select("DISTINCT refund.order_id"). + Where("refund.biz_type IN ? AND refund.status = ?", refundBizTypes(), "refunded"). + Where("refund.created_at >= ? AND refund.created_at <= ?", query.StartDate, query.EndDate) + return db.Table("payment_orders AS paid"). + Select("paid.order_id, MAX(paid.amount_cent) AS amount_cent"). + Joins("JOIN (?) AS refund_order ON refund_order.order_id = paid.order_id", refundOrders). + Where("paid.biz_type IN ? AND paid.status = ?", payBizTypes(), "paid"). + Group("paid.order_id") +} diff --git a/backend/migrations/000060_finance_dashboard_indexes.sql b/backend/migrations/000060_finance_dashboard_indexes.sql new file mode 100644 index 0000000..2a1cd14 --- /dev/null +++ b/backend/migrations/000060_finance_dashboard_indexes.sql @@ -0,0 +1,58 @@ +-- +goose Up +-- 财务仪表盘会按完成/取消日期读取提号,并按调整创建日汇总利润。 +-- 补齐复合索引,避免数据增长后统计退化为全表扫描。 +-- 该版本曾在部分开发库中部分执行,以下写法兼容已有同名索引后再次运行。 + +-- +goose StatementBegin +SET @pickup_completed_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'admin_pickups' + AND index_name = 'idx_admin_pickups_status_completed_at' +); +SET @pickup_completed_index_sql = IF( + @pickup_completed_index_exists = 0, + 'ALTER TABLE admin_pickups ADD KEY idx_admin_pickups_status_completed_at (status, completed_at)', + 'SELECT 1' +); +PREPARE pickup_completed_index_statement FROM @pickup_completed_index_sql; +EXECUTE pickup_completed_index_statement; +DEALLOCATE PREPARE pickup_completed_index_statement; + +SET @pickup_cancelled_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'admin_pickups' + AND index_name = 'idx_admin_pickups_status_cancelled_at' +); +SET @pickup_cancelled_index_sql = IF( + @pickup_cancelled_index_exists = 0, + 'ALTER TABLE admin_pickups ADD KEY idx_admin_pickups_status_cancelled_at (status, cancelled_at)', + 'SELECT 1' +); +PREPARE pickup_cancelled_index_statement FROM @pickup_cancelled_index_sql; +EXECUTE pickup_cancelled_index_statement; +DEALLOCATE PREPARE pickup_cancelled_index_statement; + +SET @pickup_adjustment_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'admin_pickup_financial_adjustments' + AND index_name = 'idx_admin_pickup_financial_adjustments_created_at' +); +SET @pickup_adjustment_index_sql = IF( + @pickup_adjustment_index_exists = 0, + 'ALTER TABLE admin_pickup_financial_adjustments ADD KEY idx_admin_pickup_financial_adjustments_created_at (created_at)', + 'SELECT 1' +); +PREPARE pickup_adjustment_index_statement FROM @pickup_adjustment_index_sql; +EXECUTE pickup_adjustment_index_statement; +DEALLOCATE PREPARE pickup_adjustment_index_statement; +-- +goose StatementEnd + +-- +goose Down + +-- 补偿迁移不删除索引,避免回滚时误删此前已存在的同名索引。 diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 704d51f..166178d 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -218,10 +218,12 @@ function diffType(value: number | undefined | null) {
{{ moneyCent(dashboard.summary.platform_income_amount_cent) }}
From 2487a2d83fbe67aed9a881ef57f21e54293bcc0f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 29 Aug 2026 01:31:08 +0800 Subject: [PATCH 47/70] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=9B=B6=E5=8F=B7?= =?UTF-8?q?=E4=B8=BB=E6=94=B6=E5=85=A5=E7=BB=93=E7=AE=97=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/order/dto.go | 3 +- .../internal/modules/order/handler_error.go | 2 + .../modules/order/platform_managed.go | 4 ++ .../order/repository_integration_test.go | 46 +++++++++++++++++++ backend/internal/modules/order/service.go | 1 + .../admin/views/AdminOrderDetailView.vue | 42 ++++++++++++++++- frontend/src/features/orders/api/orders.ts | 8 +++- 7 files changed, 101 insertions(+), 5 deletions(-) diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 7848044..ef3ca76 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -133,7 +133,8 @@ type CounterCheckoutRequest struct { } type AdminActionRequest struct { - Reason string `json:"reason" binding:"required"` + Reason string `json:"reason" binding:"required"` + ConfirmZeroOwnerIncome bool `json:"confirm_zero_owner_income"` } type PlatformHandoffRequest struct { diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index f8a8c02..acbb653 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -44,6 +44,8 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "checkout_cannot_submit", "当前订单不能发起结账") case errors.Is(err, ErrCheckoutCannotConfirm): response.Error(c, http.StatusConflict, "checkout_cannot_confirm", "当前结账不能确认") + case errors.Is(err, ErrZeroOwnerIncomeConfirmation): + response.Error(c, http.StatusConflict, "zero_owner_income_confirmation_required", "号主收入为 0,请确认后再结算") case errors.Is(err, ErrCheckoutCannotCounter): response.Error(c, http.StatusConflict, "checkout_cannot_counter", "当前结账不能修改") case errors.Is(err, ErrCheckoutMaxRounds): diff --git a/backend/internal/modules/order/platform_managed.go b/backend/internal/modules/order/platform_managed.go index 5d478de..963876e 100644 --- a/backend/internal/modules/order/platform_managed.go +++ b/backend/internal/modules/order/platform_managed.go @@ -159,6 +159,9 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u if normalizeCheckoutTurn(checkout) != checkoutTurnOwner { return ErrCheckoutCannotConfirm } + if checkout.OwnerIncomeAmountCent == 0 && !req.ConfirmZeroOwnerIncome { + return ErrZeroOwnerIncomeConfirmation + } if err := ensureCheckoutCompletable(order, checkout); err != nil { return err } @@ -196,6 +199,7 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u "after_settlement_status": order.SettlementStatus, "offline_settlement_status": order.OfflineSettlementStatus, "offline_settlement_amount_cent": order.OfflineSettlementAmountCent, + "confirm_zero_owner_income": req.ConfirmZeroOwnerIncome, }) }) if err != nil { diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index c939399..da8d2e2 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -2,6 +2,7 @@ package order import ( "context" + "errors" "testing" "time" @@ -1794,3 +1795,48 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd } return checkout } + +func TestAdminPlatformCheckoutConfirmRequiresExplicitConfirmationForZeroOwnerIncome(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + return "pending", nil + }), + }) + const adminID uint64 = 7001 + _, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID) + checkout := createOpenPlatformCheckout(t, db, order, renter.ID) + checkout.OwnerIncomeAmountCent = 0 + if err := db.Save(&checkout).Error; err != nil { + t.Fatalf("将号主收入调整为零失败: %v", err) + } + + err := repo.AdminPlatformCheckoutConfirm(t.Context(), *order.ManagedAdminID, order.ID, AdminActionRequest{ + Reason: "客服核对结算", + }, AuditMeta{}) + if !errors.Is(err, ErrZeroOwnerIncomeConfirmation) { + t.Fatalf("未确认零收入时 error = %v, want %v", err, ErrZeroOwnerIncomeConfirmation) + } + + var pending model.RentalOrder + if err := db.First(&pending, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if pending.Status != orderStatusPendingCheckoutConfirm { + t.Fatalf("未确认零收入后订单状态 = %s, want %s", pending.Status, orderStatusPendingCheckoutConfirm) + } + + if err := repo.AdminPlatformCheckoutConfirm(t.Context(), *order.ManagedAdminID, order.ID, AdminActionRequest{ + Reason: "已确认号主收入为零", + ConfirmZeroOwnerIncome: true, + }, AuditMeta{}); err != nil { + t.Fatalf("确认零收入后结算失败: %v", err) + } + var completed model.RentalOrder + if err := db.First(&completed, order.ID).Error; err != nil { + t.Fatalf("读取完成订单失败: %v", err) + } + if completed.Status != orderStatusCompleted { + t.Fatalf("确认零收入后订单状态 = %s, want %s", completed.Status, orderStatusCompleted) + } +} diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index c08605c..74b7269 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -22,6 +22,7 @@ var ( ErrOrderCannotComplete = errors.New("order cannot complete") ErrCheckoutCannotSubmit = errors.New("checkout cannot submit") ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm") + ErrZeroOwnerIncomeConfirmation = errors.New("zero owner income confirmation required") ErrCheckoutCannotCounter = errors.New("checkout cannot counter") ErrCheckoutMaxRounds = errors.New("checkout max rounds reached") ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall") diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index fcb18d1..01d4aec 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -68,6 +68,7 @@ type OrderActionType = | '' const actionType = ref('') const reason = ref('') +const zeroOwnerIncomeConfirmed = ref(false) const refundStatus = ref(null) const platformHandoffVisible = ref(false) const platformHandoffContent = ref('') @@ -476,7 +477,11 @@ async function submitAction() { await adminResetHandoff(order.value.id, reason.value) ElMessage.success(`${resetActionLabel.value}成功`) } else if (actionType.value === 'platform_checkout_confirm') { - await adminPlatformCheckoutConfirm(order.value.id, reason.value) + await adminPlatformCheckoutConfirm( + order.value.id, + reason.value, + zeroOwnerIncomeConfirmed.value + ) ElMessage.success('结账已确认') } else if (actionType.value === 'platform_checkout_dispute') { await adminPlatformCheckoutDispute(order.value.id, reason.value) @@ -500,6 +505,28 @@ async function submitAction() { } } +async function confirmPlatformCheckout() { + if (!order.value) return + zeroOwnerIncomeConfirmed.value = false + if (Number(order.value.checkout?.owner_income_amount_cent ?? 0) === 0) { + try { + await ElMessageBox.confirm( + '本次结算的号主收入为 ¥0.0。确认后将完成结算,请再次确认是否继续。', + '号主收入为零', + { + confirmButtonText: '仍确认结算', + cancelButtonText: '取消', + type: 'warning', + } + ) + } catch { + return + } + zeroOwnerIncomeConfirmed.value = true + } + openAction('platform_checkout_confirm') +} + function openPlatformHandoff() { platformHandoffContent.value = '' platformHandoffReason.value = '' @@ -952,7 +979,7 @@ function paymentPaidAt(record: AdminPayment) { {{ platformCheckoutConfirmAction?.label || '客服确认结账' }} @@ -1500,6 +1527,17 @@ function paymentPaidAt(record: AdminPayment) {

{{ order.order_no }} · 商品编号 {{ listingCode }} · {{ order.title }}

+ >( `/admin/orders/${id}/platform-checkout/confirm`, - { reason } + { reason, confirm_zero_owner_income: confirmZeroOwnerIncome } ) return data.data } From 0aef80fd11e550058100617c551612bba3e192ca Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 19:27:11 +0800 Subject: [PATCH 48/70] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=8F=B7=E4=B8=BB=E5=AE=9E=E9=99=85=E6=94=B6?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + backend/internal/modules/order/queries.go | 44 +++++++++++++++++++ .../order/repository_integration_test.go | 32 ++++++++++++++ .../features/admin/views/AdminOrdersView.vue | 19 ++++++-- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 3e0d8ba..87194f1 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ deploy/caddy/conf.d/show.caddy logs .gocache +artifacts/ diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index 5967d53..c6ffd28 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -61,11 +61,20 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag return nil, err } + checkouts, err := r.latestAdminCheckouts(ctx, rows) + if err != nil { + return nil, err + } items := make([]OrderDTO, 0, len(rows)) paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db) for _, row := range rows { dto := row.toAdminDTO() applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes) + if checkout, ok := checkouts[row.ID]; ok && shouldAttachAdminCheckout(row) { + checkoutDTO := toCheckoutAdminDTO(checkout) + refreshCheckoutSettlementDTO(&checkoutDTO, row.RentalOrder, checkout) + dto.Checkout = &checkoutDTO + } items = append(items, dto) } @@ -77,6 +86,41 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag }, nil } +// latestAdminCheckouts loads one latest checkout per order so the admin order +// list can display finalized income without issuing one query per row. +func (r *Repository) latestAdminCheckouts(ctx context.Context, rows []orderRow) (map[uint64]model.OrderCheckout, error) { + result := make(map[uint64]model.OrderCheckout, len(rows)) + if len(rows) == 0 { + return result, nil + } + orderIDs := make([]uint64, 0, len(rows)) + for _, row := range rows { + orderIDs = append(orderIDs, row.ID) + } + latest := r.db.WithContext(ctx). + Table("order_checkouts"). + Select("MAX(id) AS id"). + Where("order_id IN ?", orderIDs). + Group("order_id") + var checkouts []model.OrderCheckout + if err := r.db.WithContext(ctx). + Table("order_checkouts AS oc"). + Joins("JOIN (?) AS latest ON latest.id = oc.id", latest). + Find(&checkouts).Error; err != nil { + return nil, err + } + for _, checkout := range checkouts { + result[checkout.OrderID] = checkout + } + return result, nil +} + +func shouldAttachAdminCheckout(row orderRow) bool { + return shouldAttachCheckout(row.Status) || + row.SettlementStatus == settlementStatusSettled || + row.SettlementStatus == settlementStatusArbitrated +} + func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB { if query.Status != "" { db = db.Where("o.status = ?", query.Status) diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index da8d2e2..0dfdea4 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -1796,6 +1796,38 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd return checkout } +func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + adminID := uint64(7002) + _, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID) + order.Status = orderStatusCompleted + order.SettlementStatus = settlementStatusSettled + if err := db.Save(&order).Error; err != nil { + t.Fatalf("save completed order failed: %v", err) + } + checkout := createOpenPlatformCheckout(t, db, order, renter.ID) + checkout.Status = checkoutStatusAccepted + checkout.OwnerIncomeAmountCent = 6000 + if err := db.Save(&checkout).Error; err != nil { + t.Fatalf("save finalized checkout failed: %v", err) + } + + result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("ListAdmin() error = %v", err) + } + if len(result.Items) != 1 { + t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items)) + } + if result.Items[0].Checkout == nil || result.Items[0].Checkout.OwnerIncomeAmountCent == nil { + t.Fatal("ListAdmin() did not include finalized checkout owner income") + } + if *result.Items[0].Checkout.OwnerIncomeAmountCent != 6000 { + t.Fatalf("owner income = %d, want 6000", *result.Items[0].Checkout.OwnerIncomeAmountCent) + } +} + func TestAdminPlatformCheckoutConfirmRequiresExplicitConfirmationForZeroOwnerIncome(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db, Dependencies{ diff --git a/frontend/src/features/admin/views/AdminOrdersView.vue b/frontend/src/features/admin/views/AdminOrdersView.vue index f9f48b2..b59a4df 100644 --- a/frontend/src/features/admin/views/AdminOrdersView.vue +++ b/frontend/src/features/admin/views/AdminOrdersView.vue @@ -164,6 +164,12 @@ function userText(value: string | number | undefined) { function isPlatformManaged(row: Order) { return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' } + +function ownerActualIncome(row: Order) { + if (row.settlement_status !== 'settled' && row.settlement_status !== 'arbitrated') return null + const value = row.checkout?.owner_income_amount_cent + return typeof value === 'number' ? value : null +} - + @@ -453,6 +460,10 @@ function isPlatformManaged(row: Order) { font-size: 14px; } +.owner-income-empty { + color: #8f9bba; +} + .order-link { overflow: hidden; color: #2f6bff; From 98f6ee94514c0546093f18f014fb06400fdff59d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 19:44:08 +0800 Subject: [PATCH 49/70] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AE=A2=E6=9C=8D?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E8=B4=A6=E5=8F=B7=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/admindashboard/dto.go | 21 +++-- .../modules/admindashboard/repository.go | 76 ++++++++++++++-- .../modules/admindashboard/repository_test.go | 41 +++++++++ ...061_listing_upload_uploader_date_index.sql | 9 ++ .../src/features/admin/api/adminDashboard.ts | 11 +++ .../admin/views/AdminDashboardView.vue | 86 +++++++++++++++++++ 6 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/000061_listing_upload_uploader_date_index.sql diff --git a/backend/internal/modules/admindashboard/dto.go b/backend/internal/modules/admindashboard/dto.go index 2bf981d..d49d382 100644 --- a/backend/internal/modules/admindashboard/dto.go +++ b/backend/internal/modules/admindashboard/dto.go @@ -24,12 +24,14 @@ type MetricsDTO struct { // ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。 type ListingDailyOverviewDTO struct { - Today ListingDayStatsDTO `json:"today"` - Trend []ListingDayStatsDTO `json:"trend"` - TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"` - ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"` - Days int `json:"days"` - Timezone string `json:"timezone"` + Today ListingDayStatsDTO `json:"today"` + Trend []ListingDayStatsDTO `json:"trend"` + TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"` + ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"` + TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"` + UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"` + Days int `json:"days"` + Timezone string `json:"timezone"` } type ListingDayStatsDTO struct { @@ -47,6 +49,13 @@ type ListingChannelDayStatsDTO struct { TradeLeaveCount int64 `json:"trade_leave_count"` } +type ListingUploaderDayStatsDTO struct { + Date string `json:"date"` // YYYY-MM-DD(上海时区) + UploaderID uint64 `json:"uploader_id"` + UploaderName string `json:"uploader_name"` + UploadCount int64 `json:"upload_count"` +} + type PendingDTO struct { ListingReviews int64 `json:"listing_reviews"` Disputes int64 `json:"disputes"` diff --git a/backend/internal/modules/admindashboard/repository.go b/backend/internal/modules/admindashboard/repository.go index 1623e76..8ea685d 100644 --- a/backend/internal/modules/admindashboard/repository.go +++ b/backend/internal/modules/admindashboard/repository.go @@ -130,9 +130,18 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da Scan(&events).Error; err != nil { return ListingDailyOverviewDTO{}, err } + uploads := make([]listingUploaderRow, 0) + if err := db.Table("listing_uploads"). + Select("COALESCE(matched_admin_id, 0) AS uploader_id, uploader_name, created_at"). + Where("listing_id IS NOT NULL"). + Where("created_at >= ? AND created_at < ?", start, end). + Scan(&uploads).Error; err != nil { + return ListingDailyOverviewDTO{}, err + } byDay := make(map[string]*listingDailyBucket, days) byChannel := make(map[listingChannelKey]*listingDailyBucket) + byUploader := make(map[listingUploaderKey]int64) for _, ev := range events { key := ev.CreatedAt.In(loc).Format("2006-01-02") b := byDay[key] @@ -152,6 +161,11 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da } addListingDailyEvent(cb, ev.EventType, ev.Source) } + for _, upload := range uploads { + key := upload.CreatedAt.In(loc).Format("2006-01-02") + uploaderKey := listingUploaderKey{date: key, uploaderID: upload.UploaderID, uploaderName: upload.UploaderName} + byUploader[uploaderKey]++ + } trend := make([]ListingDayStatsDTO, 0, days) for i := days - 1; i >= 0; i-- { @@ -171,27 +185,48 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da todayStats = trend[len(trend)-1] } channelTrend := listingChannelTrend(byChannel) + uploaderTrend := listingUploaderTrend(byUploader) todayChannels := make([]ListingChannelDayStatsDTO, 0) for _, item := range channelTrend { if item.Date == todayStats.Date { todayChannels = append(todayChannels, item) } } + todayUploaders := make([]ListingUploaderDayStatsDTO, 0) + for _, item := range uploaderTrend { + if item.Date == todayStats.Date { + todayUploaders = append(todayUploaders, item) + } + } return ListingDailyOverviewDTO{ - Today: todayStats, - Trend: trend, - TodayChannels: todayChannels, - ChannelTrend: channelTrend, - Days: days, - Timezone: "Asia/Shanghai", + Today: todayStats, + Trend: trend, + TodayChannels: todayChannels, + ChannelTrend: channelTrend, + TodayUploaders: todayUploaders, + UploaderTrend: uploaderTrend, + Days: days, + Timezone: "Asia/Shanghai", }, nil } +type listingUploaderRow struct { + UploaderID uint64 + UploaderName string + CreatedAt time.Time +} + type listingChannelKey struct { date string sourceChannel string } +type listingUploaderKey struct { + date string + uploaderID uint64 + uploaderName string +} + type listingDailyBucket struct { published int64 activeOffline int64 @@ -244,6 +279,35 @@ func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []Listi return items } +func listingUploaderTrend(rows map[listingUploaderKey]int64) []ListingUploaderDayStatsDTO { + keys := make([]listingUploaderKey, 0, len(rows)) + for key := range rows { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].date != keys[j].date { + return keys[i].date < keys[j].date + } + if rows[keys[i]] != rows[keys[j]] { + return rows[keys[i]] > rows[keys[j]] + } + if keys[i].uploaderName != keys[j].uploaderName { + return keys[i].uploaderName < keys[j].uploaderName + } + return keys[i].uploaderID < keys[j].uploaderID + }) + items := make([]ListingUploaderDayStatsDTO, 0, len(keys)) + for _, key := range keys { + items = append(items, ListingUploaderDayStatsDTO{ + Date: key.date, + UploaderID: key.uploaderID, + UploaderName: key.uploaderName, + UploadCount: rows[key], + }) + } + return items +} + func listingChannelRank(channel string) int { switch channel { case "咸鱼": diff --git a/backend/internal/modules/admindashboard/repository_test.go b/backend/internal/modules/admindashboard/repository_test.go index 5ae4f82..fe9b0fc 100644 --- a/backend/internal/modules/admindashboard/repository_test.go +++ b/backend/internal/modules/admindashboard/repository_test.go @@ -65,3 +65,44 @@ func assertChannelStats(t *testing.T, item ListingChannelDayStatsDTO, published, t.Fatalf("channel %q stats = %#v, want published=%d offline=%d tradeLeave=%d", item.SourceChannel, item, published, offline, tradeLeave) } } + +func TestListingDailyOverviewGroupsExternalUploadsByUploader(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.ListingStatusEvent{}, &model.ListingUpload{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + loc := timeutil.ShanghaiLocation() + now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc) + uploaderX := uint64(11) + uploaderY := uint64(12) + listingOne := uint64(101) + listingTwo := uint64(102) + listingThree := uint64(103) + uploads := []model.ListingUpload{ + {UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now}, + {UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingTwo, CreatedAt: now.Add(time.Hour)}, + {UploaderName: "小美", MatchedAdminID: &uploaderY, ListingID: &listingThree, CreatedAt: now.Add(2 * time.Hour)}, + {UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now.AddDate(0, 0, -1)}, + } + if err := db.Create(&uploads).Error; err != nil { + t.Fatalf("create uploads error = %v", err) + } + + overview, err := NewRepository(db).listingDailyOverview(t.Context(), now, 2) + if err != nil { + t.Fatalf("listingDailyOverview() error = %v", err) + } + if len(overview.TodayUploaders) != 2 { + t.Fatalf("today uploaders = %#v, want 2 rows", overview.TodayUploaders) + } + counts := make(map[string]int64) + for _, item := range overview.TodayUploaders { + counts[item.UploaderName] = item.UploadCount + } + if counts["小晴"] != 2 || counts["小美"] != 1 { + t.Fatalf("today uploader counts = %#v, want 小晴=2, 小美=1", counts) + } + if len(overview.UploaderTrend) != 3 { + t.Fatalf("uploader trend = %#v, want 3 rows", overview.UploaderTrend) + } +} diff --git a/backend/migrations/000061_listing_upload_uploader_date_index.sql b/backend/migrations/000061_listing_upload_uploader_date_index.sql new file mode 100644 index 0000000..aee4363 --- /dev/null +++ b/backend/migrations/000061_listing_upload_uploader_date_index.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE listing_uploads + ADD KEY idx_listing_uploads_created_admin (created_at, matched_admin_id); + +-- +goose Down + +ALTER TABLE listing_uploads + DROP KEY idx_listing_uploads_created_admin; diff --git a/frontend/src/features/admin/api/adminDashboard.ts b/frontend/src/features/admin/api/adminDashboard.ts index 862348d..38c536d 100644 --- a/frontend/src/features/admin/api/adminDashboard.ts +++ b/frontend/src/features/admin/api/adminDashboard.ts @@ -36,10 +36,19 @@ export interface ListingDailyOverview { trend: ListingDayStats[] today_channels: ListingChannelDayStats[] channel_trend: ListingChannelDayStats[] + today_uploaders: ListingUploaderDayStats[] + uploader_trend: ListingUploaderDayStats[] days: number timezone: string } +export interface ListingUploaderDayStats { + date: string + uploader_id: number + uploader_name: string + upload_count: number +} + export interface DashboardRecentOrder { id: number order_no: string @@ -90,6 +99,8 @@ export async function fetchAdminDashboard() { trend: listingDaily?.trend ?? [], today_channels: listingDaily?.today_channels ?? [], channel_trend: listingDaily?.channel_trend ?? [], + today_uploaders: listingDaily?.today_uploaders ?? [], + uploader_trend: listingDaily?.uploader_trend ?? [], days: listingDaily?.days ?? 7, timezone: listingDaily?.timezone ?? 'Asia/Shanghai', }, diff --git a/frontend/src/features/admin/views/AdminDashboardView.vue b/frontend/src/features/admin/views/AdminDashboardView.vue index c30af10..dd1339d 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.vue +++ b/frontend/src/features/admin/views/AdminDashboardView.vue @@ -25,6 +25,7 @@ import { type AdminDashboard, type ListingChannelDayStats, type ListingDayStats, + type ListingUploaderDayStats, } from '@/features/admin/api/adminDashboard' import { useAdminTable } from '@/features/admin/composables/useAdminTable' import { formatCentWithSymbol } from '@/shared/utils/money' @@ -43,6 +44,7 @@ const { const listingTrend = computed(() => dashboard.value?.listing_daily?.trend ?? []) const listingChannelTrend = computed(() => dashboard.value?.listing_daily?.channel_trend ?? []) +const listingUploaderTrend = computed(() => dashboard.value?.listing_daily?.uploader_trend ?? []) const listingToday = computed( () => dashboard.value?.listing_daily?.today ?? { @@ -67,6 +69,14 @@ const listingTodayChannels = computed(() => { const extras = rows.filter(row => !channelDisplayOrder.includes(row.source_channel || '未填写')) return [...ordered, ...extras] }) +const listingTodayUploaders = computed(() => { + const date = listingToday.value.date + return (dashboard.value?.listing_daily?.today_uploaders ?? []) + .filter(row => row.date === date) + .sort( + (a, b) => b.upload_count - a.upload_count || a.uploader_name.localeCompare(b.uploader_name) + ) +}) function leaveCount(row: ListingChannelDayStats | ListingDayStats) { return Number(row.active_offline_count || 0) + Number(row.trade_leave_count || 0) @@ -311,6 +321,21 @@ function shortDate(date: string) { +
+
今日外部上传账号统计
+
+
+ {{ row.uploader_name || '未填写' }} + {{ row.upload_count }} + 个账号 +
+
+
+
@@ -381,6 +406,24 @@ function shortDate(date: string) {
+ +
+
近 7 日客服上传账号明细
+
+ + + + + + + +
+
@@ -1148,6 +1191,49 @@ function shortDate(date: string) { margin-top: 20px; } +.listing-uploader-block { + margin-top: 20px; +} + +.listing-uploader-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.listing-uploader-item { + display: flex; + align-items: baseline; + gap: 8px; + border: 1px solid #dbeafe; + border-radius: 10px; + background: #eff6ff; + padding: 14px; +} + +.uploader-name { + overflow: hidden; + color: #1e3a8a; + font-size: 13.5px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uploader-total { + margin-left: auto; + color: #2563eb; + font-size: 20px; + font-weight: 800; +} + +.uploader-unit { + color: #64748b; + font-size: 11.5px; + white-space: nowrap; +} + .channel-badge { font-weight: 600; } From 2337395d8f92ff8f0ba4ba888450e96eebaa5d82 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 20:37:41 +0800 Subject: [PATCH 50/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=AE=A1?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E6=8A=BC=E9=87=91=E8=B5=94=E4=BB=98=E5=BE=85?= =?UTF-8?q?=E6=89=93=E6=AC=BE=E9=87=91=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/dispute/arbitration.go | 50 +++++++--- backend/internal/modules/dispute/dto.go | 71 +++++++------- backend/internal/modules/dispute/presenter.go | 96 ++++++++++--------- backend/internal/modules/dispute/query.go | 3 +- .../modules/dispute/repository_test.go | 96 +++++++++++++++++-- backend/internal/modules/dispute/service.go | 3 + backend/internal/modules/order/presenter.go | 2 +- backend/internal/modules/order/pricing.go | 11 ++- .../order/repository_integration_test.go | 14 +++ .../admin/views/AdminDisputesView.vue | 2 +- .../src/features/disputes/api/disputes.ts | 1 + 11 files changed, 238 insertions(+), 111 deletions(-) diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index 158171e..0f5a6ac 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -153,7 +153,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r "order_no": order.OrderNo, "result": req.Result, "remark": req.Remark, - "input_amount_cent": req.AmountCent, + "input_amount_cent": arbitrationInputAmountCent(req), "renter_refund_amount_cent": settlement.RenterRefundAmountCent, "owner_income_amount_cent": settlement.OwnerIncomeAmountCent, "deposit_deduct_amount_cent": settlement.DepositDeductAmountCent, @@ -245,7 +245,7 @@ func buildActualCheckoutArbitrationSettlement(tx *gorm.DB, row model.Dispute, or if err != nil { return settlement, nil, linkedCheckout, 0, err } - depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, req) + depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, linkedCheckout, req) if err != nil { return settlement, nil, linkedCheckout, 0, err } @@ -299,21 +299,24 @@ func arbitrationConsumableAmountCent(order model.RentalOrder, checkout *model.Or return 0, ErrInvalidDispute } -func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRequest) (int64, error) { +func arbitrationDepositDeductAmountCent(order model.RentalOrder, checkout *model.OrderCheckout, req ArbitrateRequest) (int64, error) { switch req.Result { case "actual_settlement": - if req.AmountCent <= 0 { + if req.AmountCent == nil { + if checkout != nil { + return arbitrationCheckoutDepositDeductCent(*checkout), nil + } return 0, nil } - if req.AmountCent > order.DepositAmountCent { + if *req.AmountCent < 0 || *req.AmountCent > order.DepositAmountCent { return 0, ErrInvalidDispute } - return req.AmountCent, nil + return *req.AmountCent, nil case "release_deposit": return 0, nil case "deduct_deposit", "compensate_owner": - deductAmountCent := req.AmountCent - if deductAmountCent <= 0 { + deductAmountCent := arbitrationInputAmountCent(req) + if req.AmountCent == nil || deductAmountCent <= 0 { deductAmountCent = order.DepositAmountCent } if deductAmountCent > order.DepositAmountCent { @@ -325,6 +328,23 @@ func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRe } } +func arbitrationInputAmountCent(req ArbitrateRequest) int64 { + if req.AmountCent == nil { + return 0 + } + return *req.AmountCent +} + +func arbitrationCheckoutDepositDeductCent(checkout model.OrderCheckout) int64 { + if checkout.DepositDeductAmountCent > 0 { + return checkout.DepositDeductAmountCent + } + if checkout.OtherAmountCent > 0 { + return checkout.OtherAmountCent + } + return 0 +} + func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.OrderCheckout, settlement ordermodule.ActualCheckoutSettlement, depositDeductAmountCent int64) { order.ActualCoinConsumedM = settlement.CoinConsumedM order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent @@ -350,7 +370,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model. } func isPlatformManagedOrder(order model.RentalOrder) bool { - return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform" + return order.SettlementMode == "platform_managed" } func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error { @@ -459,15 +479,15 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r addRenterRefund(totalCent, "仲裁全额退款") settlement.RenterDepositRefundCent = depositAmountCent case "partial_refund": - if req.AmountCent <= 0 || req.AmountCent > totalCent { + if req.AmountCent == nil || *req.AmountCent <= 0 || *req.AmountCent > totalCent { return settlement, ErrInvalidDispute } - addRenterRefund(req.AmountCent, "仲裁部分退款") + addRenterRefund(*req.AmountCent, "仲裁部分退款") // 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。 - settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent) + settlement.RenterDepositRefundCent = money.MinCent(*req.AmountCent, depositAmountCent) // 号主仅拿「未退租金中的号主份额 + 未退押金」,平台加价按未退租金比例预留,避免整笔剩余进号主。 addOwnerIncome( - partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, req.AmountCent), + partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, *req.AmountCent), "仲裁剩余金额结算给号主", ) case "release_deposit": @@ -475,8 +495,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r addRenterRefund(depositAmountCent, "仲裁释放押金给租客") settlement.RenterDepositRefundCent = depositAmountCent case "deduct_deposit", "compensate_owner": - deductAmountCent := req.AmountCent - if deductAmountCent <= 0 { + deductAmountCent := arbitrationInputAmountCent(req) + if req.AmountCent == nil || deductAmountCent <= 0 { deductAmountCent = depositAmountCent } if deductAmountCent > depositAmountCent { diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index d57364c..272ea54 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -9,40 +9,41 @@ import ( ) type DisputeDTO struct { - ID uint64 `json:"id"` - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - OrderStatus string `json:"order_status"` - HandoffStatus string `json:"handoff_status"` - SettlementStatus string `json:"settlement_status"` - ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"` - ListingNo string `json:"listing_no"` - Title string `json:"title"` - OwnerID uint64 `json:"owner_id"` - RenterID uint64 `json:"renter_id"` - OwnerPhone string `json:"owner_phone"` - RenterPhone string `json:"renter_phone"` - InitiatorID uint64 `json:"initiator_id"` - InitiatorType string `json:"initiator_type"` - InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` - TargetUserID uint64 `json:"target_user_id"` - Type string `json:"type"` - Status string `json:"status"` - Description string `json:"description"` - EvidenceURLS datatypes.JSON `json:"evidence_urls"` - PreviousOrderStatus string `json:"previous_order_status"` - PreviousHandoffStatus string `json:"previous_handoff_status"` - PreviousSettlementStatus string `json:"previous_settlement_status"` - CheckoutID *uint64 `json:"checkout_id"` - CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"` - CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"` - PreviousCheckoutStatus string `json:"previous_checkout_status"` - ArbitrationResult string `json:"arbitration_result"` - ArbitrationRemark string `json:"arbitration_remark"` - HandledBy *uint64 `json:"handled_by"` - HandledAt *time.Time `json:"handled_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + OrderStatus string `json:"order_status"` + HandoffStatus string `json:"handoff_status"` + SettlementStatus string `json:"settlement_status"` + ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"` + ListingNo string `json:"listing_no"` + Title string `json:"title"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + OwnerPhone string `json:"owner_phone"` + RenterPhone string `json:"renter_phone"` + InitiatorID uint64 `json:"initiator_id"` + InitiatorType string `json:"initiator_type"` + InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` + TargetUserID uint64 `json:"target_user_id"` + Type string `json:"type"` + Status string `json:"status"` + Description string `json:"description"` + EvidenceURLS datatypes.JSON `json:"evidence_urls"` + PreviousOrderStatus string `json:"previous_order_status"` + PreviousHandoffStatus string `json:"previous_handoff_status"` + PreviousSettlementStatus string `json:"previous_settlement_status"` + CheckoutID *uint64 `json:"checkout_id"` + CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"` + CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"` + CheckoutDepositDeductAmountCent *int64 `json:"checkout_deposit_deduct_amount_cent,omitempty"` + PreviousCheckoutStatus string `json:"previous_checkout_status"` + ArbitrationResult string `json:"arbitration_result"` + ArbitrationRemark string `json:"arbitration_remark"` + HandledBy *uint64 `json:"handled_by"` + HandledAt *time.Time `json:"handled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateRequest struct { @@ -61,7 +62,7 @@ type AdminCreateRequest struct { type ArbitrateRequest struct { Result string `json:"result" binding:"required"` Remark string `json:"remark" binding:"required"` - AmountCent int64 `json:"amount_cent"` + AmountCent *int64 `json:"amount_cent"` ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"` ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"` } diff --git a/backend/internal/modules/dispute/presenter.go b/backend/internal/modules/dispute/presenter.go index 1740fc5..18069df 100644 --- a/backend/internal/modules/dispute/presenter.go +++ b/backend/internal/modules/dispute/presenter.go @@ -10,57 +10,59 @@ import ( type disputeRow struct { model.Dispute - OrderNo string - OrderStatus string - HandoffStatus string - SettlementStatus string - ExtraItemOriginalAmountCent int64 - ListingNo string - Title string - OwnerID uint64 - RenterID uint64 - OwnerPhone string - RenterPhone string - CheckoutCoinConsumedM *float64 - CheckoutConsumableAmountCent *int64 + OrderNo string + OrderStatus string + HandoffStatus string + SettlementStatus string + ExtraItemOriginalAmountCent int64 + ListingNo string + Title string + OwnerID uint64 + RenterID uint64 + OwnerPhone string + RenterPhone string + CheckoutCoinConsumedM *float64 + CheckoutConsumableAmountCent *int64 + CheckoutDepositDeductAmountCent *int64 } func (row disputeRow) toDTO() DisputeDTO { return DisputeDTO{ - ID: row.ID, - OrderID: row.OrderID, - OrderNo: row.OrderNo, - OrderStatus: row.OrderStatus, - HandoffStatus: row.HandoffStatus, - SettlementStatus: row.SettlementStatus, - ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent, - ListingNo: row.ListingNo, - Title: row.Title, - OwnerID: row.OwnerID, - RenterID: row.RenterID, - OwnerPhone: row.OwnerPhone, - RenterPhone: row.RenterPhone, - InitiatorID: row.InitiatorID, - InitiatorType: effectiveInitiatorType(row.Dispute), - InitiatorAdminID: row.InitiatorAdminID, - TargetUserID: row.TargetUserID, - Type: row.Type, - Status: row.Status, - Description: row.Description, - EvidenceURLS: row.EvidenceURLS, - PreviousOrderStatus: row.PreviousOrderStatus, - PreviousHandoffStatus: row.PreviousHandoffStatus, - PreviousSettlementStatus: row.PreviousSettlementStatus, - CheckoutID: row.CheckoutID, - CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, - CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent, - PreviousCheckoutStatus: row.PreviousCheckoutStatus, - ArbitrationResult: row.ArbitrationResult, - ArbitrationRemark: row.ArbitrationRemark, - HandledBy: row.HandledBy, - HandledAt: row.HandledAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: row.ID, + OrderID: row.OrderID, + OrderNo: row.OrderNo, + OrderStatus: row.OrderStatus, + HandoffStatus: row.HandoffStatus, + SettlementStatus: row.SettlementStatus, + ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent, + ListingNo: row.ListingNo, + Title: row.Title, + OwnerID: row.OwnerID, + RenterID: row.RenterID, + OwnerPhone: row.OwnerPhone, + RenterPhone: row.RenterPhone, + InitiatorID: row.InitiatorID, + InitiatorType: effectiveInitiatorType(row.Dispute), + InitiatorAdminID: row.InitiatorAdminID, + TargetUserID: row.TargetUserID, + Type: row.Type, + Status: row.Status, + Description: row.Description, + EvidenceURLS: row.EvidenceURLS, + PreviousOrderStatus: row.PreviousOrderStatus, + PreviousHandoffStatus: row.PreviousHandoffStatus, + PreviousSettlementStatus: row.PreviousSettlementStatus, + CheckoutID: row.CheckoutID, + CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, + CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent, + CheckoutDepositDeductAmountCent: row.CheckoutDepositDeductAmountCent, + PreviousCheckoutStatus: row.PreviousCheckoutStatus, + ArbitrationResult: row.ArbitrationResult, + ArbitrationRemark: row.ArbitrationRemark, + HandledBy: row.HandledBy, + HandledAt: row.HandledAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } } diff --git a/backend/internal/modules/dispute/query.go b/backend/internal/modules/dispute/query.go index 6d86f91..f18a2ac 100644 --- a/backend/internal/modules/dispute/query.go +++ b/backend/internal/modules/dispute/query.go @@ -94,7 +94,8 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { o.owner_id, o.renter_id, o.extra_item_original_amount_cent, owner.phone AS owner_phone, renter.phone AS renter_phone, l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m, - c.consumable_amount_cent AS checkout_consumable_amount_cent`) + c.consumable_amount_cent AS checkout_consumable_amount_cent, + CASE WHEN c.deposit_deduct_amount_cent > 0 THEN c.deposit_deduct_amount_cent ELSE c.other_amount_cent END AS checkout_deposit_deduct_amount_cent`) } func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB { diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index 68fad02..a6772e2 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -14,6 +14,8 @@ import ( "gorm.io/gorm" ) +func int64Ptr(value int64) *int64 { return &value } + func disputePureCoinOrder() model.RentalOrder { return model.RentalOrder{ Status: "renting", @@ -322,6 +324,64 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) { } } +func TestPlatformManagedCheckoutArbitrationKeepsExistingDepositCompensation(t *testing.T) { + db := setupDisputeTestDB(t) + repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + return "refunded", nil + })}) + adminID := uint64(78) + _, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{ + Status: "pending_checkout_confirm", + HandoffStatus: "pending_owner_checkout", + SettlementStatus: "pending", + HandoffMode: "platform", + SettlementMode: "platform_managed", + ManagedAdminID: &adminID, + RentAmountCent: 10000, + OwnerRentAmountCent: 8000, + PlatformFeeCent: 2000, + DepositAmountCent: 5000, + }) + checkout := model.OrderCheckout{ + OrderID: order.ID, + InitiatedBy: renter.ID, + Status: "submitted", + CoinConsumedM: 0, + DepositDeductAmountCent: 3000, + OtherAmountCent: 3000, + } + if err := db.Create(&checkout).Error; err != nil { + t.Fatalf("创建结账记录失败: %v", err) + } + created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{ + Type: "checkout_amount", + Description: "结账押金损耗争议", + }) + if err != nil { + t.Fatalf("创建结账争议失败: %v", err) + } + if created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 { + t.Fatalf("争议未带出押金赔付金额: %v", created.CheckoutDepositDeductAmountCent) + } + actualCoinConsumedM := 0.0 + actualConsumableAmountCent := int64(0) + if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{ + Result: "actual_settlement", + Remark: "沿用原结账押金赔付", + ActualCoinConsumedM: &actualCoinConsumedM, + ActualConsumableAmountCent: &actualConsumableAmountCent, + }, AuditMeta{}); err != nil { + t.Fatalf("仲裁失败: %v", err) + } + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("读取仲裁订单失败: %v", err) + } + if saved.OfflineSettlementAmountCent != 11000 { + t.Fatalf("仲裁后待打款 = %d, want 11000", saved.OfflineSettlementAmountCent) + } +} + func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) { db := setupDisputeTestDB(t) repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { @@ -394,7 +454,7 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) { settlement, actualSettlement, _, depositDeductAmountCent, err = buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{ Result: "actual_settlement", Remark: "赔付号主", - AmountCent: compensationCent, + AmountCent: &compensationCent, ActualCoinConsumedM: &actualCoinConsumedM, ActualConsumableAmountCent: &actualConsumableAmountCent, }, 0) @@ -406,6 +466,21 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) { } } +func TestActualSettlementArbitrationInheritsCheckoutDepositCompensation(t *testing.T) { + order := disputePureCoinOrder() + checkout := &model.OrderCheckout{DepositDeductAmountCent: 3000} + amount, err := arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement"}) + if err != nil || amount != 3000 { + t.Fatalf("omitted arbitration compensation = %d/%v, want 3000/nil", amount, err) + } + + zero := int64(0) + amount, err = arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement", AmountCent: &zero}) + if err != nil || amount != 0 { + t.Fatalf("explicit zero arbitration compensation = %d/%v, want 0/nil", amount, err) + } +} + func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { db := setupDisputeTestDB(t) repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { @@ -417,11 +492,12 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { inputOrder.SettlementStatus = "pending" _, renter, order := createDisputeOrderFixture(t, db, inputOrder) checkout := model.OrderCheckout{ - OrderID: order.ID, - InitiatedBy: renter.ID, - Status: "submitted", - ConsumableAmountCent: 7000, - CoinConsumedM: 25, + OrderID: order.ID, + InitiatedBy: renter.ID, + Status: "submitted", + ConsumableAmountCent: 7000, + CoinConsumedM: 25, + DepositDeductAmountCent: 3000, } if err := db.Create(&checkout).Error; err != nil { t.Fatalf("创建结账记录失败: %v", err) @@ -433,8 +509,8 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { if err != nil { t.Fatalf("创建结账争议失败: %v", err) } - if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 { - t.Fatalf("关联结账/M/额外物品 = %v/%v/%v, want %d/25/7000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, checkout.ID) + if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 || created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 { + t.Fatalf("关联结账/M/额外物品/押金赔付 = %v/%v/%v/%v, want %d/25/7000/3000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, created.CheckoutDepositDeductAmountCent, checkout.ID) } if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{ @@ -566,7 +642,7 @@ func TestPartialRefundReservesPlatformFee(t *testing.T) { settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ Result: "partial_refund", - AmountCent: 5000, + AmountCent: int64Ptr(5000), Remark: "没打完 号主登录不上", }, 0) if err != nil { @@ -599,7 +675,7 @@ func TestPartialRefundFullRentRetainedGivesOwnerFullOwnerRent(t *testing.T) { } settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ Result: "partial_refund", - AmountCent: 20000, + AmountCent: int64Ptr(20000), }, 0) if err != nil { t.Fatalf("buildArbitrationSettlement() error = %v", err) diff --git a/backend/internal/modules/dispute/service.go b/backend/internal/modules/dispute/service.go index 099c8d5..819e1a8 100644 --- a/backend/internal/modules/dispute/service.go +++ b/backend/internal/modules/dispute/service.go @@ -93,6 +93,9 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) { return nil, ErrInvalidDispute } + if req.AmountCent != nil && (*req.AmountCent < 0 || *req.AmountCent > 100_000_000_000) { + return nil, ErrInvalidDispute + } if req.ActualConsumableAmountCent != nil && (*req.ActualConsumableAmountCent < 0 || *req.ActualConsumableAmountCent > 100_000_000_000) { return nil, ErrInvalidDispute } diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 0282517..2abe4b7 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -289,7 +289,7 @@ func refreshCheckoutSettlementDTO(dto *CheckoutDTO, order model.RentalOrder, che if checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered { return } - settlement := calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent) + settlement := calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, effectiveCheckoutDepositDeductCent(checkout)) rent := settlement.ActualRentAmountCent ownerRent := settlement.OwnerRentIncomeCent platform := settlement.PlatformFeeCent diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index 47c6569..e7424c2 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -278,7 +278,16 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c } func buildCheckoutSettlement(order model.RentalOrder, checkout *model.OrderCheckout) checkoutSettlement { - return calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent) + return calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, effectiveCheckoutDepositDeductCent(*checkout)) +} + +// effectiveCheckoutDepositDeductCent keeps legacy checkouts that only populated +// other_amount_cent from losing their deposit compensation at finalization. +func effectiveCheckoutDepositDeductCent(checkout model.OrderCheckout) int64 { + if checkout.DepositDeductAmountCent > 0 { + return checkout.DepositDeductAmountCent + } + return maxCent(checkout.OtherAmountCent, 0) } func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent int64, coinConsumedM float64, depositDeductAmountCent int64) checkoutSettlement { diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 0dfdea4..5d2dd05 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -381,6 +381,20 @@ func TestBuildCheckoutSettlementWithDepositDeduct(t *testing.T) { } } +func TestBuildCheckoutSettlementFallsBackToLegacyOtherAmount(t *testing.T) { + order := model.RentalOrder{ + RentAmountCent: 24000, + OwnerRentAmountCent: 21600, + DepositAmountCent: 5000, + } + checkout := &model.OrderCheckout{OtherAmountCent: 2000} + + settlement := buildCheckoutSettlement(order, checkout) + if settlement.DepositCompensationCent != 2000 || settlement.OwnerIncomeCent != 23600 { + t.Fatalf("legacy other amount settlement = compensation %d, owner income %d, want 2000/23600", settlement.DepositCompensationCent, settlement.OwnerIncomeCent) + } +} + // TestBuildCheckoutSettlementWithConsumable 测试额外消耗 func TestBuildCheckoutSettlementWithConsumable(t *testing.T) { order := model.RentalOrder{ diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index e279011..11d5d86 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -184,7 +184,7 @@ function openArbitration(row: Dispute) { activeDispute.value = row result.value = editableArbitrationResult(row.arbitration_result) remark.value = row.arbitration_remark || '' - amount.value = undefined + amount.value = centToYuan(row.checkout_deposit_deduct_amount_cent ?? 0) actualCoinConsumedM.value = row.checkout_coin_consumed_m ?? undefined actualConsumableAmountYuan.value = centToYuan( row.checkout_consumable_amount_cent ?? row.extra_item_original_amount_cent ?? 0 diff --git a/frontend/src/features/disputes/api/disputes.ts b/frontend/src/features/disputes/api/disputes.ts index 67913fb..018d11a 100644 --- a/frontend/src/features/disputes/api/disputes.ts +++ b/frontend/src/features/disputes/api/disputes.ts @@ -31,6 +31,7 @@ export interface Dispute { checkout_id?: number checkout_coin_consumed_m?: number checkout_consumable_amount_cent?: number + checkout_deposit_deduct_amount_cent?: number previous_checkout_status?: string arbitration_result: string arbitration_remark: string From 21fbc69c7ad72ea57b00e04654991ed2117a3b41 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 21:05:28 +0800 Subject: [PATCH 51/70] =?UTF-8?q?=E8=A1=A5=E5=85=85=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E5=90=8E=E8=AE=A2=E5=8D=95=E5=88=A9=E6=B6=A6=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/结算后订单利润调整方案.md | 166 +++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/结算后订单利润调整方案.md diff --git a/docs/结算后订单利润调整方案.md b/docs/结算后订单利润调整方案.md new file mode 100644 index 0000000..0c25dfe --- /dev/null +++ b/docs/结算后订单利润调整方案.md @@ -0,0 +1,166 @@ +# 结算后订单利润调整方案 + +## 1. 背景与目标 + +订单完成结算后,用户可能继续发生售后补偿、部分退款、号主收入修正等情况。需要在不破坏正式结算快照和历史资金流水的前提下,记录每次调整,并展示订单的最终实际收入和平台利润。 + +本方案针对正常租赁订单,提号订单已有类似实现,可复用其“追加调整流水”的思路。 + +## 2. 当前系统结算口径 + +- 正式结算基准使用最终结算记录 `order_checkouts` 的正式金额,不能使用发布价格、预计价格或订单初始价格。 +- 号主正式收入为 `order_checkouts.owner_income_amount_cent`。 +- 普通订单结算时通过 `wallet_ledger` 给号主入账。 +- 平台代管订单结算时把待付号主金额记录在 `rental_orders.offline_settlement_amount_cent`。 +- 订单过程留痕使用 `process_events`,管理员操作审计使用 `audit_logs`。 +- 财务明细目前通过结算记录、号主钱包入账和平台代管线下结算金额计算平台净额。 + +相关代码: + +- `backend/internal/modules/order/checkout_finalize.go` +- `backend/internal/modules/order/queries.go` +- `backend/internal/modules/adminfinance/detail.go` +- `backend/internal/model/order_checkout.go` +- `backend/internal/modules/pickup/repository.go` + +## 3. 核心设计 + +不要修改原始结算记录、订单线下结算金额或已有钱包流水。新增不可变的订单财务调整流水: + +```text +admin_order_financial_adjustments +``` + +建议字段: + +```text +id +order_id +adjustment_no 唯一调整单号,用于幂等 +settlement_mode owner_wallet / platform_managed +owner_income_delta_cent 号主收入调整,可正可负 +renter_compensation_cent 本次租客补偿金额 +compensation_bearer platform / owner +platform_profit_delta_cent 系统计算的利润变化,不允许前端直接填写 +owner_settlement_status settled / pending / recovery_pending +renter_refund_status none / pending / refunded / failed +reason +reference_no 售后单号或外部凭证号 +created_by +processed_by +processed_at +remark +created_at +``` + +最终结果按流水累计: + +```text +最终号主收入 = 正式结算号主收入 + 所有有效 owner_income_delta_cent +最终租客退款 = 正式结算退款 + 所有已完成 renter_compensation_cent +最终平台利润 = 正式结算利润 + 所有 platform_profit_delta_cent +``` + +`platform_profit_delta_cent` 由服务端根据补偿承担方和号主收入变化自动计算,避免出现“号主收入、租客退款、平台利润”三者不一致。 + +## 4. 后台操作口径 + +后台输入“目标最终金额”,系统自动计算差额,不建议让客服手工输入差额: + +1. 展示正式结算号主收入和当前实际号主收入。 +2. 输入调整后的号主最终收入(可不调整)。 +3. 输入本次售后补偿金额。 +4. 选择补偿承担方:平台或号主。 +5. 填写售后单号、原因和备注。 +6. 提交前预览本次差额、最终号主收入、最终平台利润和待处理资金状态。 + +示例:正式号主收入 100 元,平台利润 20 元,售后补偿 3 元: + +- 平台承担:号主仍为 100 元,平台利润减少 3 元。 +- 号主承担:号主最终收入减少 3 元,并产生 3 元退款;平台利润原则上不变。 + +## 5. 资金处理 + +### 普通号主钱包订单 + +- 增加号主收入:在同一数据库事务中追加钱包入账流水。 +- 减少号主收入:余额足够时追加钱包扣款流水。 +- 余额不足:记录 `recovery_pending`,不能把钱包扣成负数,后续由财务处理追回。 +- 平台承担租客补偿:提交支付退款任务,退款完成后再将调整标记为已处理。 + +### 平台代管订单 + +- 原线下款尚未支付:调整金额并入待打款任务,原始结算金额保持不变。 +- 原线下款已支付:增加新的待补款或待追回任务。 +- 不直接覆盖 `offline_settlement_amount_cent`,否则会丢失首次结算记录。 + +### 退款幂等 + +当前退款逻辑按订单和 `biz_type` 查找已有退款单。多次售后不能共用同一个退款业务键,应使用 `adjustment_no` 作为每次退款的唯一幂等键,并补充退款重试和失败状态。 + +## 6. API、权限与页面 + +建议接口: + +```text +GET /admin/orders/:id/financial-adjustments +POST /admin/orders/:id/financial-adjustments +POST /admin/order-financial-adjustments/:id/settle +``` + +新增权限: + +```text +order:financial_adjust +``` + +不建议复用订单关闭或标记异常权限。 + +订单详情的结算区域增加: + +- 正式结算号主收入 +- 售后调整合计 +- 当前实际号主收入 +- 正式退款与售后补偿 +- 当前实际平台利润 +- 调整流水、退款状态、补款/追回状态 +- “新增售后补偿/修改利润”按钮 + +需要同步修改订单详情、财务明细、财务仪表盘和线下出款待办的聚合查询。 + +## 7. 状态与审计 + +号主资金和租客退款可能分别处于不同状态,因此不能只使用一个总状态。建议分别保存号主结算状态和租客退款状态,再由接口计算调整单的整体状态。 + +创建调整、钱包入账/扣款、退款任务创建、退款完成/失败、线下补款/追回确认,都要写入 `process_events` 和 `audit_logs`,并记录调整前后金额、操作人、原因和凭证号。 + +## 8. 测试范围 + +- 普通订单增加或减少号主收入。 +- 钱包余额不足进入待追回。 +- 平台代管未打款时调整。 +- 平台代管已打款后补款或追回。 +- 平台承担补偿与号主承担补偿。 +- 退款失败、重试和重复请求幂等。 +- 同一订单多次售后调整的累计结果。 +- 正式结算记录和原始钱包流水不可被修改。 +- 财务明细、仪表盘和出款待办金额一致。 + +## 9. 分支核查(2026-08-30) + +- 当前 `main`:`98f6ee9`,包含最近的客服上传账号统计。 +- 本地 `dev`:`85332df`。 +- 远端 `gitea/dev`:`85332df`,与本地 `dev` 一致。 +- `dev` 包含 `80e55ba 支持提号完成后财务调整`,相关实现位于 `admin_pickup_financial_adjustments`、提号仓库/服务和提号管理页面。 +- `dev` 尚未包含正常租赁订单的结算后利润调整。 +- `dev` 与 `main` 的共同基点为 `f48da14`;`dev` 有 1 个独有提交,`main` 有 49 个后续提交。不能直接整体合并 `dev`,如需保留其中的接口最小化和私有文件加固,应单独评审后再移植 `85332df`。 + +## 10. 实施顺序 + +1. 增加迁移和 `AdminOrderFinancialAdjustment` 模型。 +2. 增加订单调整仓库、服务、接口、权限和审计。 +3. 接入钱包、退款幂等键、平台代管补款/追回。 +4. 修改订单详情和财务聚合查询。 +5. 补齐普通订单、平台代管、余额不足、退款失败和重复调整测试。 + +实施前需要确认:补偿承担方规则、退款是否全部原路退回、以及是否统一采用“目标最终金额”输入。推荐采用本文方案。 From 327a03672a3953df7c21fc623464e2ffb079130d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 21:19:47 +0800 Subject: [PATCH 52/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=86=BB=E7=BB=93?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8D=B3=E6=97=B6=E4=B8=8B=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/router/router.go | 3 + frontend/src/App.vue | 2 + frontend/src/features/auth/api/auth.ts | 4 +- .../auth/composables/useSessionMonitor.ts | 117 ++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 frontend/src/features/auth/composables/useSessionMonitor.ts diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 5eb043e..aea3188 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -427,6 +427,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { authRoutes.POST("/password/reset", authHandler.ResetPassword) authRoutes.POST("/refresh", authHandler.Refresh) authRoutes.POST("/logout", authHandler.Logout) + if chatHubHandler != nil { + authRoutes.GET("/events", requireAuth, chatHubHandler.UserEvents) + } } api.GET("/me", requireAuth, userHandler.Me) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e27b114..d3d860b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,10 +5,12 @@ import { computed } from 'vue' import { RouterView, useRoute } from 'vue-router' import InAppBrowserPrompt from './components/InAppBrowserPrompt.vue' +import { useSessionMonitor } from './features/auth/composables/useSessionMonitor' import AdminLayout from './layouts/AdminLayout.vue' import AppLayout from './layouts/AppLayout.vue' const route = useRoute() +useSessionMonitor() const layout = computed(() => { if (route.path === '/m' || route.path.startsWith('/m/')) { return 'blank' diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index 0e88ea2..8406864 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -80,8 +80,8 @@ export async function resetPassword(phone: string, code: string, newPassword: st await apiClient.post('/auth/password/reset', { phone, code, new_password: newPassword }) } -export async function fetchMe() { - const { data } = await apiClient.get>('/me') +export async function fetchMe(options?: { silent?: boolean }) { + const { data } = await apiClient.get>('/me', options) return data.data } diff --git a/frontend/src/features/auth/composables/useSessionMonitor.ts b/frontend/src/features/auth/composables/useSessionMonitor.ts new file mode 100644 index 0000000..1c4676e --- /dev/null +++ b/frontend/src/features/auth/composables/useSessionMonitor.ts @@ -0,0 +1,117 @@ +import { onBeforeUnmount, onMounted, watch } from 'vue' + +import { fetchMe } from '@/features/auth/api/auth' +import { getAccessToken, type AuthScope } from '@/shared/utils/authStorage' +import { useSessionStore } from '@/stores/session' + +const scope: AuthScope = 'user' +const reconnectDelay = 3000 +const verificationInterval = 5000 + +// 保持一个轻量级会话通道,使后台冻结能在没有页面请求时立即让用户退出。 +export function useSessionMonitor() { + const session = useSessionStore() + let source: EventSource | null = null + let reconnectTimer: number | null = null + let verificationTimer: number | null = null + let checking = false + let stopped = false + + function closeSource() { + source?.close() + source = null + } + + function scheduleReconnect() { + if (stopped || reconnectTimer !== null || !session.isLoggedIn) return + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null + connect() + }, reconnectDelay) + } + + async function verifySession() { + if (stopped || checking || !session.isLoggedIn) return + checking = true + try { + await fetchMe({ silent: true }) + } catch { + // 认证失败由 axios 拦截器清理令牌并跳转;网络错误等待下一次校验。 + } finally { + checking = false + } + } + + function connect() { + if (stopped || source || !session.isLoggedIn) return + const token = getAccessToken(scope) + if (!token) return + + source = new EventSource(`/api/auth/events?token=${encodeURIComponent(token)}`, { + withCredentials: true, + }) + source.addEventListener('connected', () => undefined) + source.onerror = () => { + closeSource() + void verifySession() + scheduleReconnect() + } + } + + function stop() { + closeSource() + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer) + reconnectTimer = null + } + if (verificationTimer !== null) { + window.clearInterval(verificationTimer) + verificationTimer = null + } + } + + function start() { + stop() + if (!session.isLoggedIn) return + connect() + void verifySession() + verificationTimer = window.setInterval(verifySession, verificationInterval) + } + + function handleAuthStorageChanged(event: Event) { + const detail = (event as CustomEvent<{ scope?: AuthScope }>).detail + if (detail?.scope !== scope || stopped) return + if (session.isLoggedIn) { + start() + } else { + stop() + } + } + + function handleVisibilityChange() { + if (document.visibilityState === 'visible') void verifySession() + } + + onMounted(() => { + stopped = false + start() + window.addEventListener('auth-storage-changed', handleAuthStorageChanged) + document.addEventListener('visibilitychange', handleVisibilityChange) + }) + + onBeforeUnmount(() => { + stopped = true + stop() + window.removeEventListener('auth-storage-changed', handleAuthStorageChanged) + document.removeEventListener('visibilitychange', handleVisibilityChange) + }) + + watch( + () => session.isLoggedIn, + loggedIn => { + if (stopped) return + if (loggedIn) start() + else stop() + } + ) +} From 6561f925e027701265650a24b36491f31311c313 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 21:56:22 +0800 Subject: [PATCH 53/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=88=B7=E6=96=B0=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/adminfinance/disbursement.go | 81 ++++++++----------- .../admin/views/AdminFinanceDetailsView.vue | 2 +- .../views/AdminFinanceDisbursementsView.vue | 2 +- .../views/AdminOperatingExpensesView.vue | 2 +- .../admin/views/AdminPaymentsView.vue | 2 +- .../admin/views/AdminWalletLedgerView.vue | 2 +- .../admin/views/AdminWithdrawalsView.vue | 2 +- 7 files changed, 40 insertions(+), 53 deletions(-) diff --git a/backend/internal/modules/adminfinance/disbursement.go b/backend/internal/modules/adminfinance/disbursement.go index 84ecaa2..df46323 100644 --- a/backend/internal/modules/adminfinance/disbursement.go +++ b/backend/internal/modules/adminfinance/disbursement.go @@ -98,42 +98,29 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue return nil, err } - var withdrawalPaid struct { - AmountCent int64 - FeeAmountCent int64 - ActualAmountCent int64 - Count int64 + // 付款、处理中、待审核三类提现在一次条件聚合中完成,避免同一张表重复扫描。 + var withdrawal struct { + PaidAmountCent int64 + PaidFeeAmountCent int64 + PaidActualAmountCent int64 + PaidCount int64 + PendingAmountCent int64 + PendingCount int64 + ReviewAmountCent int64 + ReviewCount int64 } - if err := db.Table("withdrawal_requests"). - Select(`COALESCE(SUM(amount_cent), 0) AS amount_cent, - COALESCE(SUM(fee_cent), 0) AS fee_amount_cent, - COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, - COUNT(id) AS count`). - Where("status = ?", "completed"). - Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate). - Scan(&withdrawalPaid).Error; err != nil { - return nil, err - } - - var withdrawalPending struct { - ActualAmountCent int64 - Count int64 - } - if err := db.Table("withdrawal_requests"). - Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`). - Where("status = ?", "processing"). - Scan(&withdrawalPending).Error; err != nil { - return nil, err - } - - var withdrawalReview struct { - ActualAmountCent int64 - Count int64 - } - if err := db.Table("withdrawal_requests"). - Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`). - Where("status = ?", "pending"). - Scan(&withdrawalReview).Error; err != nil { + if err := db.Table("withdrawal_requests").Select(` + COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent, + COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN fee_cent ELSE 0 END), 0) AS paid_fee_amount_cent, + COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN actual_amount_cent ELSE 0 END), 0) AS paid_actual_amount_cent, + COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN 1 ELSE 0 END), 0) AS paid_count, + COALESCE(SUM(CASE WHEN status = 'processing' THEN actual_amount_cent ELSE 0 END), 0) AS pending_amount_cent, + COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS pending_count, + COALESCE(SUM(CASE WHEN status = 'pending' THEN actual_amount_cent ELSE 0 END), 0) AS review_amount_cent, + COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS review_count`, + query.StartDate, query.EndDate, query.StartDate, query.EndDate, + query.StartDate, query.EndDate, query.StartDate, query.EndDate). + Scan(&withdrawal).Error; err != nil { return nil, err } @@ -150,22 +137,22 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue } return &DisbursementSummaryDTO{ - PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent, - PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawalPaid.Count + manualPaid.Count, - PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawalPending.ActualAmountCent, - PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawalPending.Count, + PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawal.PaidActualAmountCent + manualPaid.AmountCent, + PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawal.PaidCount + manualPaid.Count, + PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawal.PendingAmountCent, + PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawal.PendingCount, OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent, OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count, OfflineSettlementPendingAmountCent: offlinePending.AmountCent, OfflineSettlementPendingCount: offlinePending.Count, - WithdrawalAmountCent: withdrawalPaid.AmountCent, - WithdrawalFeeAmountCent: withdrawalPaid.FeeAmountCent, - WithdrawalPaidAmountCent: withdrawalPaid.ActualAmountCent, - WithdrawalPaidCount: withdrawalPaid.Count, - WithdrawalPendingAmountCent: withdrawalPending.ActualAmountCent, - WithdrawalPendingCount: withdrawalPending.Count, - WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent, - WithdrawalReviewCount: withdrawalReview.Count, + WithdrawalAmountCent: withdrawal.PaidAmountCent, + WithdrawalFeeAmountCent: withdrawal.PaidFeeAmountCent, + WithdrawalPaidAmountCent: withdrawal.PaidActualAmountCent, + WithdrawalPaidCount: withdrawal.PaidCount, + WithdrawalPendingAmountCent: withdrawal.PendingAmountCent, + WithdrawalPendingCount: withdrawal.PendingCount, + WithdrawalReviewAmountCent: withdrawal.ReviewAmountCent, + WithdrawalReviewCount: withdrawal.ReviewCount, ManualPaidAmountCent: manualPaid.AmountCent, ManualPaidCount: manualPaid.Count, }, nil diff --git a/frontend/src/features/admin/views/AdminFinanceDetailsView.vue b/frontend/src/features/admin/views/AdminFinanceDetailsView.vue index 0031c5c..80241ad 100644 --- a/frontend/src/features/admin/views/AdminFinanceDetailsView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDetailsView.vue @@ -404,7 +404,7 @@ function defaultEndDate() { v-model:current-page="currentPage" v-model:page-size="currentPageSize" :total="total" - @change="handlePageChange" + @page-change="handlePageChange" /> diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue index 2083413..732b6e2 100644 --- a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue @@ -673,7 +673,7 @@ function accountTypeLabel(type: string) { v-model:current-page="currentPage" v-model:page-size="currentPageSize" :total="total" - @change="loadItems" + @page-change="loadItems" /> diff --git a/frontend/src/features/admin/views/AdminOperatingExpensesView.vue b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue index 675094a..323040d 100644 --- a/frontend/src/features/admin/views/AdminOperatingExpensesView.vue +++ b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue @@ -405,7 +405,7 @@ function statusType(status: string): 'success' | 'danger' { v-model:current-page="currentPage" v-model:page-size="currentPageSize" :total="total" - @change="loadItems" + @page-change="loadItems" /> diff --git a/frontend/src/features/admin/views/AdminPaymentsView.vue b/frontend/src/features/admin/views/AdminPaymentsView.vue index ba34dc2..e125c1a 100644 --- a/frontend/src/features/admin/views/AdminPaymentsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentsView.vue @@ -356,7 +356,7 @@ function jsonText(value: unknown) { v-model:current-page="currentPage" v-model:page-size="currentPageSize" :total="total" - @change="handlePageChange" + @page-change="handlePageChange" /> diff --git a/frontend/src/features/admin/views/AdminWalletLedgerView.vue b/frontend/src/features/admin/views/AdminWalletLedgerView.vue index d6e5a12..7a2314c 100644 --- a/frontend/src/features/admin/views/AdminWalletLedgerView.vue +++ b/frontend/src/features/admin/views/AdminWalletLedgerView.vue @@ -267,7 +267,7 @@ function directionLabel(direction: string) { v-model:current-page="currentPage" v-model:page-size="currentPageSize" :total="total" - @change="handlePageChange" + @page-change="handlePageChange" /> diff --git a/frontend/src/features/admin/views/AdminWithdrawalsView.vue b/frontend/src/features/admin/views/AdminWithdrawalsView.vue index dc729be..32a324f 100644 --- a/frontend/src/features/admin/views/AdminWithdrawalsView.vue +++ b/frontend/src/features/admin/views/AdminWithdrawalsView.vue @@ -469,7 +469,7 @@ function money(cent: number | undefined | null) { v-model:current-page="currentPage" v-model:page-size="pageSize" :total="total" - @change="loadWithdrawals" + @page-change="loadWithdrawals" /> From cfabfa8f651976f74517637ecae8376dfd3b4eb6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 22:00:52 +0800 Subject: [PATCH 54/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=B1=A1=E6=9F=93=E9=80=89=E5=8F=B7=E7=BD=91?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy-prod.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 249eaa9..cef2192 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -96,6 +96,7 @@ RESET_DB=0 FOLLOW_LOGS=0 SKIP_CHECK=0 NO_CLEAN=0 +SHOW_DIST_BACKUP_DIR="" while [[ $# -gt 0 ]]; do case "$1" in @@ -261,6 +262,26 @@ export_caddy_env() { export CADDY_SHOW_DOMAIN } +restore_show_dist_placeholders() { + if [[ -z "${SHOW_DIST_BACKUP_DIR}" || ! -d "${SHOW_DIST_BACKUP_DIR}" ]]; then + return 0 + fi + local show_dist="${ROOT_DIR}/deploy/caddy/show-dist" + local git_restore_dir + git_restore_dir="$(mktemp -d "${TMPDIR:-/tmp}/hfb-show-dist-restore.XXXXXX")" + rm -rf "${show_dist}" + mkdir -p "${show_dist}" + if git -C "${ROOT_DIR}" show HEAD:deploy/caddy/show-dist/.gitkeep >"${git_restore_dir}/.gitkeep" 2>/dev/null \ + && git -C "${ROOT_DIR}" show HEAD:deploy/caddy/show-dist/index.html >"${git_restore_dir}/index.html" 2>/dev/null; then + cp -a "${git_restore_dir}/." "${show_dist}/" + else + cp -a "${SHOW_DIST_BACKUP_DIR}/." "${show_dist}/" + fi + rm -rf "${git_restore_dir}" + rm -rf "${SHOW_DIST_BACKUP_DIR}" + SHOW_DIST_BACKUP_DIR="" +} + # 准备选号网静态资源与 Caddy 站点配置(可选) prepare_show_site() { local conf_dir="${ROOT_DIR}/deploy/caddy/conf.d" @@ -355,6 +376,10 @@ prepare_show_site() { log_error "若仍网络失败,可在 backend/.env 设置 NPM_REGISTRY=https://registry.npmmirror.com 后重试" exit 1 fi + # show-dist 中的 .gitkeep/index.html 是仓库占位文件。备份后再写入构建产物, + # 退出时由 restore_show_dist_placeholders 恢复,避免每次部署污染工作区。 + SHOW_DIST_BACKUP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hfb-show-dist.XXXXXX")" + cp -a "${show_dist}/." "${SHOW_DIST_BACKUP_DIR}/" rm -rf "${show_dist}" mkdir -p "${show_dist}" cp -a "${show_dir}/dist/." "${show_dist}/" @@ -579,6 +604,7 @@ main() { need_cmd flock validate_env export_caddy_env + trap restore_show_dist_placeholders EXIT prepare_log_dir prepare_backup_status_dir # 选号网需在 build caddy 之前准备 show-dist 与 conf.d From 1ee7d17712a72de5a07f5055d8e43e8472a8a5c6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 31 Aug 2026 12:41:07 +0800 Subject: [PATCH 55/70] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E5=89=8D=E4=BB=A3=E7=A0=81=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E9=92=A9=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .githooks/pre-push | 30 ++++++++++++++++++++++++++++++ README.md | 12 ++++++++++++ scripts/install-git-hooks.sh | 6 ++++++ 3 files changed, 48 insertions(+) create mode 100755 .githooks/pre-push create mode 100755 scripts/install-git-hooks.sh diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..e34f2ac --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +push_lines="$(cat)" +has_updates=0 +has_deletions=0 +while read -r local_ref local_sha remote_ref remote_sha; do + [ -z "${local_ref}" ] && continue + if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then + has_deletions=1 + else + has_updates=1 + fi +done <<< "$push_lines" + +if [ "$has_updates" = "0" ] && [ "$has_deletions" = "1" ]; then + echo "[pre-push] 分支删除推送,跳过本地检查" + exit 0 +fi + +if [ "${SKIP_CHECKS:-0}" = "1" ]; then + echo "[pre-push] SKIP_CHECKS=1,跳过本地检查" + exit 0 +fi + +echo "[pre-push] 运行本地代码门禁..." +bash scripts/check.sh +echo "[pre-push] 本地检查通过" diff --git a/README.md b/README.md index a369adc..cad86bc 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,18 @@ npm run dev `./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。 +需要在本地推送前自动执行同一套检查时,启用 Git hook: + +```bash +bash scripts/install-git-hooks.sh +``` + +启用后,`git push` 会执行 `scripts/check.sh`;仅临时跳过时使用: + +```bash +SKIP_CHECKS=1 git push +``` + ## 开发态短信与实名 - 后端日志使用单行可读文本。开发环境同时输出控制台和 `backend/logs/app-YYYY-MM-DD.log`,生产示例只输出文件以避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。 diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..2f6eaf7 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +git -C "$ROOT_DIR" config core.hooksPath .githooks +echo "已启用 .githooks(推送前执行 scripts/check.sh;SKIP_CHECKS=1 可跳过)" From ad279049a01ecb1749ae3c9c92888461185f314f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 31 Aug 2026 12:58:28 +0800 Subject: [PATCH 56/70] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=20SQL=20=E7=BA=A6=E6=9D=9F=E4=B8=8E=E8=BF=90=E7=BB=B4?= =?UTF-8?q?=E6=B2=BB=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + backend/.env.example | 7 +++ backend/.env.prod.example | 7 +++ backend/cmd/api/main.go | 8 ++- backend/internal/config/config.go | 25 ++++++++ backend/internal/config/config_test.go | 35 +++++++++++ backend/internal/database/gorm_logger.go | 9 ++- backend/internal/database/mysql.go | 53 ++++++++++++++-- deploy/mysql/my.cnf | 6 ++ docs/数据库SQL约束与运维.md | 77 ++++++++++++++++++++++++ scripts/check-sql-guard.sh | 60 ++++++++++++++++++ scripts/check.sh | 2 + 12 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 docs/数据库SQL约束与运维.md create mode 100755 scripts/check-sql-guard.sh diff --git a/README.md b/README.md index cad86bc..6f62fae 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ npm run dev `./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。 +数据库 SQL 约束、执行计划、慢查询观测、连接池和备份恢复要求见 [`docs/数据库SQL约束与运维.md`](docs/数据库SQL约束与运维.md)。 + 需要在本地推送前自动执行同一套检查时,启用 Git hook: ```bash diff --git a/backend/.env.example b/backend/.env.example index 915f436..b68228a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,13 @@ MYSQL_USER=hfb MYSQL_PASSWORD=secret MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local +# 数据库连接池与慢查询观测;生产按实例数和 MySQL max_connections 调整。 +DATABASE_MAX_OPEN_CONNS=50 +DATABASE_MAX_IDLE_CONNS=10 +DATABASE_CONN_MAX_LIFETIME_MINUTES=30 +DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5 +DATABASE_SLOW_QUERY_THRESHOLD_MS=500 + REDIS_ADDR=127.0.0.1:16379 REDIS_PASSWORD= REDIS_DB=0 diff --git a/backend/.env.prod.example b/backend/.env.prod.example index af1fe7c..d5707b9 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -19,6 +19,13 @@ MYSQL_USER=hfb MYSQL_PASSWORD=change-hfb-password MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local +# 数据库连接池与慢查询阈值;总连接数按实例数核算,避免超过 MySQL max_connections。 +DATABASE_MAX_OPEN_CONNS=50 +DATABASE_MAX_IDLE_CONNS=10 +DATABASE_CONN_MAX_LIFETIME_MINUTES=30 +DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5 +DATABASE_SLOW_QUERY_THRESHOLD_MS=500 + REDIS_ADDR=redis:6379 REDIS_PASSWORD= REDIS_DB=0 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 2def695..43c1750 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -58,7 +58,13 @@ func main() { logAuthRuntimeIdentity(logger, cfg) var deps router.Dependencies - db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger) + db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{ + MaxOpenConns: cfg.Database.MaxOpenConns, + MaxIdleConns: cfg.Database.MaxIdleConns, + ConnMaxLifetime: cfg.Database.ConnMaxLifetime, + ConnMaxIdleTime: cfg.Database.ConnMaxIdleTime, + SlowQueryThreshold: cfg.Database.SlowQueryThreshold, + }) if err != nil { logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err)) } else { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ec59a67..ba4fa25 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "strings" + "time" ) type Config struct { @@ -29,6 +30,7 @@ type Config struct { Realname RealnameConfig Log LogConfig RateLimit RateLimitConfig + Database DatabaseConfig BackupStatusFile string } @@ -70,6 +72,14 @@ type RateLimitConfig struct { RequestsPerMinute int } +type DatabaseConfig struct { + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + ConnMaxIdleTime time.Duration + SlowQueryThreshold time.Duration +} + func Load() Config { return Config{ AppEnv: getEnv("APP_ENV", "development"), @@ -131,6 +141,13 @@ func Load() Config { Enabled: getEnvBool("RATE_LIMIT_ENABLED", true), RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300), }, + Database: DatabaseConfig{ + MaxOpenConns: getEnvIntMin("DATABASE_MAX_OPEN_CONNS", 50, 1), + MaxIdleConns: getEnvIntMin("DATABASE_MAX_IDLE_CONNS", 10, 0), + ConnMaxLifetime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_LIFETIME_MINUTES", 30, 1)) * time.Minute, + ConnMaxIdleTime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", 5, 0)) * time.Minute, + SlowQueryThreshold: time.Duration(getEnvIntMin("DATABASE_SLOW_QUERY_THRESHOLD_MS", 500, 1)) * time.Millisecond, + }, BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"), } } @@ -208,6 +225,14 @@ func getEnvInt(key string, fallback int) int { return parsed } +func getEnvIntMin(key string, fallback, minimum int) int { + value := getEnvInt(key, fallback) + if value < minimum { + return fallback + } + return value +} + func getEnvBool(key string, fallback bool) bool { value := os.Getenv(key) if value == "" { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 53511be..e9ad4a4 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -26,6 +26,41 @@ func TestIsProductionEnv(t *testing.T) { } } +func TestLoadDatabaseConfigFromEnvironment(t *testing.T) { + t.Setenv("DATABASE_MAX_OPEN_CONNS", "80") + t.Setenv("DATABASE_MAX_IDLE_CONNS", "16") + t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "45") + t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "7") + t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "750") + + cfg := Load() + if cfg.Database.MaxOpenConns != 80 || cfg.Database.MaxIdleConns != 16 { + t.Fatalf("database pool = %+v, want open=80 idle=16", cfg.Database) + } + if cfg.Database.ConnMaxLifetime.Minutes() != 45 || cfg.Database.ConnMaxIdleTime.Minutes() != 7 { + t.Fatalf("database lifetimes = %s/%s, want 45m/7m", cfg.Database.ConnMaxLifetime, cfg.Database.ConnMaxIdleTime) + } + if cfg.Database.SlowQueryThreshold.Milliseconds() != 750 { + t.Fatalf("slow query threshold = %s, want 750ms", cfg.Database.SlowQueryThreshold) + } +} + +func TestLoadDatabaseConfigInvalidValuesUseDefaults(t *testing.T) { + t.Setenv("DATABASE_MAX_OPEN_CONNS", "0") + t.Setenv("DATABASE_MAX_IDLE_CONNS", "-1") + t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "0") + t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "-1") + t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "0") + + cfg := Load() + if cfg.Database.MaxOpenConns != 50 || cfg.Database.MaxIdleConns != 10 { + t.Fatalf("invalid pool values = %+v, want defaults open=50 idle=10", cfg.Database) + } + if cfg.Database.ConnMaxLifetime.Minutes() != 30 || cfg.Database.ConnMaxIdleTime.Minutes() != 5 || cfg.Database.SlowQueryThreshold.Milliseconds() != 500 { + t.Fatalf("invalid database durations = %+v, want defaults", cfg.Database) + } +} + // TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时 // 回退到历史硬编码密钥(开发态零配置兼容旧密文)。 func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) { diff --git a/backend/internal/database/gorm_logger.go b/backend/internal/database/gorm_logger.go index e5ca36f..8d60c62 100644 --- a/backend/internal/database/gorm_logger.go +++ b/backend/internal/database/gorm_logger.go @@ -21,6 +21,10 @@ type structuredGormLogger struct { } func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface { + return newGormLoggerWithThreshold(logLevel, logger, 500*time.Millisecond) +} + +func newGormLoggerWithThreshold(logLevel string, logger *zap.Logger, slowThreshold time.Duration) gormLogger.Interface { level := gormLogger.Warn if strings.EqualFold(strings.TrimSpace(logLevel), "debug") { level = gormLogger.Info @@ -28,10 +32,13 @@ func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface { if logger == nil { logger = zap.L() } + if slowThreshold <= 0 { + slowThreshold = 500 * time.Millisecond + } return &structuredGormLogger{ logger: logger.With(zap.String("module", "database")), level: level, - slowThreshold: 500 * time.Millisecond, + slowThreshold: slowThreshold, } } diff --git a/backend/internal/database/mysql.go b/backend/internal/database/mysql.go index 3b62fad..22ccab7 100644 --- a/backend/internal/database/mysql.go +++ b/backend/internal/database/mysql.go @@ -8,9 +8,50 @@ import ( "gorm.io/gorm" ) +type MySQLOptions struct { + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + ConnMaxIdleTime time.Duration + SlowQueryThreshold time.Duration +} + +func DefaultMySQLOptions() MySQLOptions { + return MySQLOptions{ + MaxOpenConns: 50, + MaxIdleConns: 10, + ConnMaxLifetime: 30 * time.Minute, + ConnMaxIdleTime: 5 * time.Minute, + SlowQueryThreshold: 500 * time.Millisecond, + } +} + func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) { + return OpenMySQLWithOptions(dsn, logLevel, appLogger, DefaultMySQLOptions()) +} + +func OpenMySQLWithOptions(dsn string, logLevel string, appLogger *zap.Logger, options MySQLOptions) (*gorm.DB, error) { + defaults := DefaultMySQLOptions() + if options.MaxOpenConns < 1 { + options.MaxOpenConns = defaults.MaxOpenConns + } + if options.MaxIdleConns < 0 { + options.MaxIdleConns = defaults.MaxIdleConns + } + if options.ConnMaxLifetime <= 0 { + options.ConnMaxLifetime = defaults.ConnMaxLifetime + } + if options.ConnMaxIdleTime < 0 { + options.ConnMaxIdleTime = defaults.ConnMaxIdleTime + } + if options.SlowQueryThreshold <= 0 { + options.SlowQueryThreshold = defaults.SlowQueryThreshold + } + if options.MaxIdleConns > options.MaxOpenConns { + options.MaxIdleConns = options.MaxOpenConns + } db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ - Logger: newGormLogger(logLevel, appLogger), + Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold), }) if err != nil { return nil, err @@ -20,11 +61,11 @@ func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, er if err != nil { return nil, err } - // 限制连接池,避免本地压测瞬间打满 MySQL max_connections。 - sqlDB.SetMaxOpenConns(50) - sqlDB.SetMaxIdleConns(10) - sqlDB.SetConnMaxLifetime(30 * time.Minute) - sqlDB.SetConnMaxIdleTime(5 * time.Minute) + // 限制连接池,避免单个实例耗尽 MySQL max_connections。 + sqlDB.SetMaxOpenConns(options.MaxOpenConns) + sqlDB.SetMaxIdleConns(options.MaxIdleConns) + sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime) + sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime) return db, nil } diff --git a/deploy/mysql/my.cnf b/deploy/mysql/my.cnf index 81c389d..c462f3d 100644 --- a/deploy/mysql/my.cnf +++ b/deploy/mysql/my.cnf @@ -9,6 +9,12 @@ character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci init-connect = 'SET NAMES utf8mb4' +# 生产慢查询观测:应用日志记录 SQL 形状,MySQL 原生日志记录超过阈值的语句。 +# performance_schema 默认开启,使用 events_statements_summary_by_digest 做聚合分析。 +slow_query_log = ON +long_query_time = 0.5 +log_slow_admin_statements = ON + # 时间点恢复基础:binlog 由 scripts/archive-binlog.sh 每分钟归档到 OSS 备份桶。 # 本地按 7 天自动过期,归档与本地保留策略见 scripts/backup-online.sh。 server-id = 1 diff --git a/docs/数据库SQL约束与运维.md b/docs/数据库SQL约束与运维.md new file mode 100644 index 0000000..5ea65ad --- /dev/null +++ b/docs/数据库SQL约束与运维.md @@ -0,0 +1,77 @@ +# 数据库 SQL 约束与运维 + +本项目使用 MySQL 8.4、GORM 和 Goose。本文是提交、上线和故障排查的数据库基线;SQL 静态检查不能替代真实数据量下的执行计划。 + +## 提交前约束 + +新增或修改 SQL、GORM 查询或迁移必须满足: + +1. 列表查询必须有分页、最大数量或明确日期范围,禁止无界读取订单、流水、消息和日志历史。 +2. `GROUP BY`、`COUNT(DISTINCT)`、窗口函数、跨表聚合、`UNION` 和相关子查询必须在接近生产数据量的 MySQL 上保存 `EXPLAIN FORMAT=JSON` 或 `EXPLAIN ANALYZE` 结果。 +3. 高频查询先限定当前时间范围或当前页 ID,再做关联和聚合;禁止读取用户全部历史记录后在应用层过滤。 +4. 新增索引必须通过新的 Goose migration 提交,并在迁移注释中说明覆盖的查询条件、排序和基数;大表索引要评估锁影响和上线窗口。 +5. SQL 值必须使用 GORM 参数绑定或 `?` 占位符,禁止拼接请求参数。动态排序字段只能来自白名单。 +6. 已应用的迁移文件禁止修改或删除,只能新增迁移完成结构修复;除 `000001_init.sql` 外的新迁移必须包含 `-- +goose Up` 和 `-- +goose Down`。 +7. 角色、权限、推送渠道、客服组等小型配置表允许全量读取,但必须保持数量边界或在本文件的白名单中登记;业务数据不得套用该例外。 + +本地 `scripts/check.sh` 会执行 `scripts/check-sql-guard.sh`,保护关键索引、时间范围和迁移完整性。它只防止关键代码被重构移除,不判断执行计划是否优秀。 + +## 执行计划 + +在维护库或只读副本上替换真实参数后执行: + +```sql +EXPLAIN FORMAT=JSON +SELECT ...; + +EXPLAIN ANALYZE +SELECT ...; +``` + +重点检查扫描行数、实际返回行数、索引使用、临时表、文件排序和 join 顺序。财务首页的仪表盘、结算明细和提现合并列表属于必须定期复核的查询。 + +## 生产观测 + +生产 MySQL 应保持 `performance_schema` 开启,并启用慢查询日志。项目的 `deploy/mysql/my.cnf` 默认记录超过 500ms 的语句;应用日志只记录 SQL 形状、耗时和返回行数,不记录绑定参数值。 + +常用排查 SQL: + +```sql +SELECT + DIGEST_TEXT, + COUNT_STAR, + ROUND(SUM_TIMER_WAIT / 1000000000000, 2) AS total_seconds, + ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms, + SUM_ROWS_SENT, + SUM_ROWS_EXAMINED +FROM performance_schema.events_statements_summary_by_digest +ORDER BY SUM_TIMER_WAIT DESC +LIMIT 30; + +SELECT THREAD_ID, EVENT_NAME, TIMER_WAIT, SQL_TEXT, ROWS_EXAMINED +FROM performance_schema.events_statements_current +WHERE SQL_TEXT IS NOT NULL +ORDER BY TIMER_WAIT DESC; +``` + +应用慢查询阈值由 `DATABASE_SLOW_QUERY_THRESHOLD_MS` 配置。阈值调整后要同步检查 MySQL `long_query_time`,避免应用和数据库观测口径长期不一致。 + +## 连接、超时与容量 + +- `DATABASE_MAX_OPEN_CONNS` 是单个 backend 实例的连接上限,总量必须按实例数核算,不能简单随实例扩容同比增加。 +- `DATABASE_MAX_IDLE_CONNS`、`DATABASE_CONN_MAX_LIFETIME_MINUTES` 和 `DATABASE_CONN_MAX_IDLE_TIME_MINUTES` 控制空闲连接和连接轮换。 +- 请求必须设置 Go `context` 截止时间;锁等待由 MySQL `innodb_lock_wait_timeout` 控制。长报表查询应进入异步任务或汇总表。 +- 数据量增长后,排行榜、财务历史统计和审计报表应迁移到日汇总表、缓存或只读副本,不能持续依赖在线全表聚合。 +- 修改连接池或容器资源后,用 `docker compose config` 核对最终插值;资源限额应高于稳定 RSS 峰值并留出数据库连接和备份余量。 + +## 备份与恢复 + +生产使用 XtraBackup 全量备份加 MySQL ROW binlog 归档实现时间点恢复。备份对象先客户端加密,再使用独立 OSS bucket 的服务端加密;恢复必须在隔离目录和隔离 MySQL 容器演练,禁止直接向生产实例回放。 + +```bash +./scripts/backup-online.sh status +./scripts/archive-binlog.sh verify +./scripts/restore-online.sh prepare +``` + +备份完成标识、SHA-256、manifest、server UUID 和 Goose 版本必须一致;恢复演练至少按月验证一次,并记录恢复时间点和结果。 diff --git a/scripts/check-sql-guard.sh b/scripts/check-sql-guard.sh new file mode 100755 index 0000000..f9ebf11 --- /dev/null +++ b/scripts/check-sql-guard.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# 检查关键 SQL 防线是否仍存在;真实数据量下的执行计划需要在 MySQL 上单独验证。 +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations" +failures=() + +require_pattern() { + local file="$1" pattern="$2" name="$3" + if [[ ! -f "${ROOT_DIR}/${file}" ]]; then + failures+=("${name}: 文件不存在 ${file}") + elif ! grep -Fq -- "${pattern}" "${ROOT_DIR}/${file}"; then + failures+=("${name}: 缺少 ${pattern} (${file})") + fi +} + +# 数据库运行时配置和参数脱敏不能被重构删除。 +require_pattern "backend/internal/config/config.go" "DATABASE_SLOW_QUERY_THRESHOLD_MS" "慢查询阈值配置" +require_pattern "backend/internal/config/config.go" "DATABASE_MAX_OPEN_CONNS" "连接池上限配置" +require_pattern "backend/internal/database/gorm_logger.go" "ParamsFilter" "SQL 参数脱敏" +require_pattern "backend/internal/database/mysql.go" "SetMaxOpenConns" "连接池应用" + +# 当前财务仪表盘的时间范围聚合和索引是高频查询防线。 +require_pattern "backend/internal/modules/adminfinance/subquery.go" "settled_at >= ? AND scoped_order.settled_at <= ?" "结算聚合时间范围" +require_pattern "backend/internal/modules/adminfinance/dashboard.go" "po.created_at >= ? AND po.created_at <= ?" "支付聚合时间范围" +require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_completed_at" "财务完成索引" +require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_cancelled_at" "财务取消索引" + +# 分页核心路径必须保留上限;配置型全量列表在运维文档中登记为白名单。 +require_pattern "backend/internal/modules/listing/public_query.go" "if pageSize > 50" "公开商品分页上限" +require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 100" "后台接单分页上限" + +# 已发布迁移只允许新增,不允许修改或删除历史文件。 +if changed_migrations="$(git -C "${ROOT_DIR}" diff --name-status HEAD -- backend/migrations)"; then + while read -r status file _; do + [[ -z "${status:-}" ]] && continue + if [[ "${status}" != "A" ]]; then + failures+=("历史迁移文件不可修改或删除: ${status} ${file}") + fi + done <<< "${changed_migrations}" +else + failures+=("无法检查迁移文件变更") +fi + +# 初始合并迁移没有 Down,其余迁移必须提供可回滚段。 +while IFS= read -r file; do + [[ "$(basename "${file}")" == "000001_init.sql" ]] && continue + if ! grep -Fq -- '-- +goose Up' "${file}" || ! grep -Fq -- '-- +goose Down' "${file}"; then + failures+=("迁移缺少 Goose Up/Down: ${file#"${ROOT_DIR}/"}") + fi +done < <(find "${MIGRATIONS_DIR}" -maxdepth 1 -type f -name '*.sql' | sort) + +if (( ${#failures[@]} > 0 )); then + printf '[sql-guard] SQL 约束检查失败\n' >&2 + printf '[sql-guard] %s\n' "${failures[@]}" >&2 + exit 1 +fi + +printf '[sql-guard] SQL 约束检查通过\n' diff --git a/scripts/check.sh b/scripts/check.sh index e3a70a5..9329876 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -6,6 +6,7 @@ # - 手动校验:开发时手动跑 ./scripts/check.sh # # 包含: +# 数据库:SQL 约束与迁移完整性检查 # 后端:go vet + go build + go test # 前端:npm run check (prettier + eslint + typecheck) + npm run test + npm run build # @@ -91,6 +92,7 @@ if [[ "${SKIP_BACKEND}" == "0" ]]; then log "后端检查目录: ${BACKEND_DIR}" cd "${BACKEND_DIR}" + run_step "SQL 约束检查" bash "${ROOT_DIR}/scripts/check-sql-guard.sh" || FAILED=1 run_step "go vet ./..." go vet ./... || FAILED=1 run_step "go build ./..." go build ./... || FAILED=1 if [[ "${SKIP_TESTS}" == "0" ]]; then From dd2b45bd2c0b858e3ff6187b045e8d77edd8e963 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 1 Sep 2026 12:40:10 +0800 Subject: [PATCH 57/70] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8F=B7=E4=B8=BB?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/listings/views/ListingDetailView.vue | 14 +++++++------- .../listings/views/MobileListingDetailView.vue | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue index cc12e9f..eb34b75 100644 --- a/frontend/src/features/listings/views/ListingDetailView.vue +++ b/frontend/src/features/listings/views/ListingDetailView.vue @@ -427,6 +427,13 @@ function goBack() { +
+
+

号主备注

+
+

{{ listing.description || '号主暂未填写详细说明。' }}

+
+

额外消耗品

@@ -465,13 +472,6 @@ function goBack() {
-
-
-

号主备注

-
-

{{ listing.description || '号主暂未填写详细说明。' }}

-
-

账号截图

diff --git a/frontend/src/features/listings/views/MobileListingDetailView.vue b/frontend/src/features/listings/views/MobileListingDetailView.vue index 95f8d15..5ab074a 100644 --- a/frontend/src/features/listings/views/MobileListingDetailView.vue +++ b/frontend/src/features/listings/views/MobileListingDetailView.vue @@ -415,6 +415,11 @@ function handleToggleFavorite() {
+
+

号主备注

+

{{ listing.description }}

+
+

额外消耗品

@@ -445,11 +450,6 @@ function handleToggleFavorite() {
-
-

备注

-

{{ listing.description }}

-
-

账号截图

From 208f12ddb47fa36ec6a674fa5edf32d37e56a6a4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 2 Sep 2026 13:50:19 +0800 Subject: [PATCH 58/70] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E9=97=A8=E7=A6=81=E4=B8=8E=E8=B5=84=E9=87=91?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- backend/internal/e2e/rental_flow_test.go | 14 +- backend/internal/handler/health.go | 47 +- backend/internal/handler/health_test.go | 75 ++ backend/internal/jobs/refundretry/job_test.go | 45 + .../payment/repository_integration_test.go | 71 ++ .../withdrawal/repository_integration_test.go | 176 ++++ backend/internal/router/admin_routes.go | 97 ++ backend/internal/router/health_checks.go | 29 + backend/internal/router/providers.go | 48 + backend/internal/router/router.go | 126 +-- backend/internal/router/router_test.go | 68 ++ deploy/README.md | 1 + deploy/caddy/Caddyfile | 2 +- deploy/caddy/Dockerfile | 2 +- docs/business-rules.md | 33 + docs/database.md | 25 + docs/project-plan.md | 23 + docs/代码质量改进计划.md | 658 +------------ frontend/package-lock.json | 286 ++++-- frontend/package.json | 2 + .../admin/components/AdminOrderMetrics.css | 18 + .../admin/components/AdminOrderMetrics.vue | 22 + .../components/AdminOrderPaymentRecords.css | 67 ++ .../components/AdminOrderPaymentRecords.vue | 52 + .../__tests__/adminOrderPresentation.spec.ts | 42 + .../admin/utils/adminOrderPresentation.ts | 51 + .../features/admin/views/AdminChatsView.css | 560 +++++++++++ .../features/admin/views/AdminChatsView.vue | 563 +---------- .../admin/views/AdminDashboardView.css | 882 +++++++++++++++++ .../admin/views/AdminDashboardView.vue | 885 +----------------- .../admin/views/AdminDisputesView.css | 263 ++++++ .../admin/views/AdminDisputesView.vue | 266 +----- .../admin/views/AdminFinanceDashboardView.css | 662 +++++++++++++ .../admin/views/AdminFinanceDashboardView.vue | 665 +------------ .../views/AdminFinanceDisbursementsView.css | 545 +++++++++++ .../views/AdminFinanceDisbursementsView.vue | 548 +---------- .../admin/views/AdminListingDetailView.css | 489 ++++++++++ .../admin/views/AdminListingDetailView.vue | 492 +--------- .../admin/views/AdminListingReviewView.css | 590 ++++++++++++ .../admin/views/AdminListingReviewView.vue | 593 +----------- .../views/AdminOperatingExpensesView.css | 398 ++++++++ .../views/AdminOperatingExpensesView.vue | 401 +------- .../admin/views/AdminOrderDetailView.css | 318 +++++++ .../admin/views/AdminOrderDetailView.vue | 505 ++-------- .../admin/views/AdminPickupDetailView.css | 209 +++++ .../admin/views/AdminPickupDetailView.vue | 212 +---- .../features/admin/views/AdminPickupView.css | 120 +++ .../features/admin/views/AdminPickupView.vue | 123 +-- .../admin/views/AdminQrCodePoolView.css | 448 +++++++++ .../admin/views/AdminQrCodePoolView.vue | 451 +-------- .../admin/views/AdminSystemConfigsView.css | 89 ++ .../admin/views/AdminSystemConfigsView.vue | 92 +- .../features/admin/views/AdminUsersView.css | 149 +++ .../features/admin/views/AdminUsersView.vue | 152 +-- .../features/auth/views/MobileProfileView.css | 665 +++++++++++++ .../features/auth/views/MobileProfileView.vue | 668 +------------ .../financial/__tests__/financialApi.spec.ts | 93 ++ .../listings/views/ListingDetailView.css | 680 ++++++++++++++ .../listings/views/ListingDetailView.vue | 683 +------------- .../views/MobileListingDetailView.css | 485 ++++++++++ .../views/MobileListingDetailView.vue | 488 +--------- .../__tests__/useOrderPaymentCashier.spec.ts | 103 ++ .../orders/views/MobileOrderDetailView.css | 259 +++++ .../orders/views/MobileOrderDetailView.vue | 262 +----- .../features/orders/views/OrderDetailView.css | 773 +++++++++++++++ .../features/orders/views/OrderDetailView.vue | 776 +-------------- .../src/features/orders/views/OrdersView.css | 396 ++++++++ .../src/features/orders/views/OrdersView.vue | 399 +------- .../wallet/views/MobileWithdrawalView.css | 484 ++++++++++ .../wallet/views/MobileWithdrawalView.vue | 487 +--------- frontend/src/layouts/AdminLayout.css | 373 ++++++++ frontend/src/layouts/AdminLayout.vue | 376 +------- frontend/src/layouts/AppLayout.css | 732 +++++++++++++++ frontend/src/layouts/AppLayout.vue | 735 +-------------- frontend/vitest.config.ts | 17 + scripts/check-backend-coverage.sh | 49 + scripts/check-vue-size.sh | 18 + scripts/check.sh | 6 +- scripts/extract-vue-styles.sh | 23 + 80 files changed, 12245 insertions(+), 11541 deletions(-) create mode 100644 backend/internal/handler/health_test.go create mode 100644 backend/internal/modules/withdrawal/repository_integration_test.go create mode 100644 backend/internal/router/admin_routes.go create mode 100644 backend/internal/router/health_checks.go create mode 100644 backend/internal/router/providers.go create mode 100644 backend/internal/router/router_test.go create mode 100644 docs/business-rules.md create mode 100644 docs/database.md create mode 100644 docs/project-plan.md create mode 100644 frontend/src/features/admin/components/AdminOrderMetrics.css create mode 100644 frontend/src/features/admin/components/AdminOrderMetrics.vue create mode 100644 frontend/src/features/admin/components/AdminOrderPaymentRecords.css create mode 100644 frontend/src/features/admin/components/AdminOrderPaymentRecords.vue create mode 100644 frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts create mode 100644 frontend/src/features/admin/utils/adminOrderPresentation.ts create mode 100644 frontend/src/features/admin/views/AdminChatsView.css create mode 100644 frontend/src/features/admin/views/AdminDashboardView.css create mode 100644 frontend/src/features/admin/views/AdminDisputesView.css create mode 100644 frontend/src/features/admin/views/AdminFinanceDashboardView.css create mode 100644 frontend/src/features/admin/views/AdminFinanceDisbursementsView.css create mode 100644 frontend/src/features/admin/views/AdminListingDetailView.css create mode 100644 frontend/src/features/admin/views/AdminListingReviewView.css create mode 100644 frontend/src/features/admin/views/AdminOperatingExpensesView.css create mode 100644 frontend/src/features/admin/views/AdminOrderDetailView.css create mode 100644 frontend/src/features/admin/views/AdminPickupDetailView.css create mode 100644 frontend/src/features/admin/views/AdminPickupView.css create mode 100644 frontend/src/features/admin/views/AdminQrCodePoolView.css create mode 100644 frontend/src/features/admin/views/AdminSystemConfigsView.css create mode 100644 frontend/src/features/admin/views/AdminUsersView.css create mode 100644 frontend/src/features/auth/views/MobileProfileView.css create mode 100644 frontend/src/features/financial/__tests__/financialApi.spec.ts create mode 100644 frontend/src/features/listings/views/ListingDetailView.css create mode 100644 frontend/src/features/listings/views/MobileListingDetailView.css create mode 100644 frontend/src/features/orders/composables/__tests__/useOrderPaymentCashier.spec.ts create mode 100644 frontend/src/features/orders/views/MobileOrderDetailView.css create mode 100644 frontend/src/features/orders/views/OrderDetailView.css create mode 100644 frontend/src/features/orders/views/OrdersView.css create mode 100644 frontend/src/features/wallet/views/MobileWithdrawalView.css create mode 100644 frontend/src/layouts/AdminLayout.css create mode 100644 frontend/src/layouts/AppLayout.css create mode 100755 scripts/check-backend-coverage.sh create mode 100755 scripts/check-vue-size.sh create mode 100755 scripts/extract-vue-styles.sh diff --git a/README.md b/README.md index 6f62fae..f4e88c4 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ go run github.com/pressly/goose/v3/cmd/goose@v3.27.1 \ go run ./cmd/api cd ../frontend -npm install +npm ci npm run dev ``` @@ -50,6 +50,10 @@ npm run dev `./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。 +本地门禁由 `scripts/check.sh` 统一执行;后端覆盖率基线由 `scripts/check-backend-coverage.sh` 维护。需要验证完整租赁链路时,先启动本地 MySQL 8.4,再设置 `HFB_E2E_DSN` 运行 `go test ./internal/e2e`。 + +服务探针:`GET /health` 仅表示进程存活;`GET /health/ready` 和 `GET /api/health` 会检查 MySQL、Redis,任一依赖不可用时返回 503。 + 数据库 SQL 约束、执行计划、慢查询观测、连接池和备份恢复要求见 [`docs/数据库SQL约束与运维.md`](docs/数据库SQL约束与运维.md)。 需要在本地推送前自动执行同一套检查时,启用 Git hook: diff --git a/backend/internal/e2e/rental_flow_test.go b/backend/internal/e2e/rental_flow_test.go index 1d82311..cfc0263 100644 --- a/backend/internal/e2e/rental_flow_test.go +++ b/backend/internal/e2e/rental_flow_test.go @@ -170,13 +170,19 @@ func TestArbitrationReleaseDepositDoesNotRepublishListing(t *testing.T) { t.Fatalf("读取仲裁后订单模型失败: %v", err) } assertEqual(t, "仲裁退款状态", orderRow.RefundStatus, "refunded") - assertEqual(t, "仲裁退款金额", orderRow.RefundAmountCent, int64(15000)) + assertEqual(t, "仲裁退款金额", orderRow.RefundAmountCent, int64(25800)) walletAccount, err := services.wallet.Account(t.Context(), owner.ID) if err != nil { t.Fatalf("读取号主钱包失败: %v", err) } - assertEqual(t, "仲裁后号主可用余额", walletAccount.AvailableBalanceCent, int64(35300)) + assertEqual(t, "仲裁后号主可用余额", walletAccount.AvailableBalanceCent, int64(24500)) + assertEqual( + t, + "仲裁资金守恒", + orderRow.RefundAmountCent+walletAccount.AvailableBalanceCent+3000, + orderRow.RentAmountCent+orderRow.DepositAmountCent, + ) var listingRow model.RentalListing if err := db.First(&listingRow, approved.ID).Error; err != nil { @@ -432,6 +438,10 @@ func createListingUnderReview(t *testing.T, service *listing.Service, ownerID ui ScreenshotURLS: []string{"https://example.com/e2e-account.png"}, AssetSummary: map[string]any{ "fire_level": 50, + "online_time": map[string]any{ + "start": "09:00", + "end": "23:00", + }, "price_breakdown": map[string]any{ "seller_coin_base_price": 233, "seller_total_price": 353, diff --git a/backend/internal/handler/health.go b/backend/internal/handler/health.go index 256c445..c4a9cb6 100644 --- a/backend/internal/handler/health.go +++ b/backend/internal/handler/health.go @@ -1,7 +1,9 @@ package handler import ( + "context" "net/http" + "sort" "time" "github.com/gin-gonic/gin" @@ -9,16 +11,55 @@ import ( type HealthHandler struct { startedAt time.Time + checks map[string]ReadinessCheck } -func NewHealthHandler() *HealthHandler { - return &HealthHandler{startedAt: time.Now()} +type ReadinessCheck func(context.Context) error + +func NewHealthHandler(checks map[string]ReadinessCheck) *HealthHandler { + return &HealthHandler{startedAt: time.Now(), checks: checks} } -func (h *HealthHandler) Check(c *gin.Context) { +func (h *HealthHandler) Live(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "service": "hfb-api", "started_at": h.startedAt.Format(time.RFC3339), }) } + +func (h *HealthHandler) Ready(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + + names := make([]string, 0, len(h.checks)) + for name := range h.checks { + names = append(names, name) + } + sort.Strings(names) + + ready := true + dependencies := make(map[string]string, len(names)) + for _, name := range names { + check := h.checks[name] + if check == nil || check(ctx) != nil { + dependencies[name] = "unavailable" + ready = false + continue + } + dependencies[name] = "ok" + } + + statusCode := http.StatusOK + status := "ok" + if !ready { + statusCode = http.StatusServiceUnavailable + status = "unavailable" + } + c.JSON(statusCode, gin.H{ + "status": status, + "service": "hfb-api", + "started_at": h.startedAt.Format(time.RFC3339), + "dependencies": dependencies, + }) +} diff --git a/backend/internal/handler/health_test.go b/backend/internal/handler/health_test.go new file mode 100644 index 0000000..a5fbac5 --- /dev/null +++ b/backend/internal/handler/health_test.go @@ -0,0 +1,75 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestHealthLiveDoesNotDependOnReadiness(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewHealthHandler(map[string]ReadinessCheck{ + "mysql": func(context.Context) error { return errors.New("down") }, + }) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/health", nil) + + h.Live(ctx) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } +} + +func TestHealthReadyReportsDependencyState(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + checks map[string]ReadinessCheck + wantStatus int + wantBody string + }{ + { + name: "all dependencies ready", + checks: map[string]ReadinessCheck{ + "mysql": func(context.Context) error { return nil }, + "redis": func(context.Context) error { return nil }, + }, + wantStatus: http.StatusOK, + wantBody: `"status":"ok"`, + }, + { + name: "dependency unavailable", + checks: map[string]ReadinessCheck{ + "mysql": func(context.Context) error { return errors.New("down") }, + "redis": func(context.Context) error { return nil }, + }, + wantStatus: http.StatusServiceUnavailable, + wantBody: `"mysql":"unavailable"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHealthHandler(tt.checks) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/health/ready", nil) + + h.Ready(ctx) + + if recorder.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.wantStatus, recorder.Body.String()) + } + if body := recorder.Body.String(); !strings.Contains(body, tt.wantBody) { + t.Fatalf("body = %s, want substring %s", body, tt.wantBody) + } + }) + } +} diff --git a/backend/internal/jobs/refundretry/job_test.go b/backend/internal/jobs/refundretry/job_test.go index b9068e3..9bc299d 100644 --- a/backend/internal/jobs/refundretry/job_test.go +++ b/backend/internal/jobs/refundretry/job_test.go @@ -161,6 +161,51 @@ func TestResetRetryClearsRetryFields(t *testing.T) { } } +func TestSyncRefundPaymentsMarksFailedAttemptForRetry(t *testing.T) { + db := setupRefundRetryTestDB(t) + repo := payment.NewRepository(db, nil, nil) + job := New(db, nil, zap.NewNop(), repo) + now := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) + row := model.PaymentOrder{ + PaymentNo: "PAY-RETRY-FAIL", + OrderID: 1, + OrderNo: "ORD-RETRY-FAIL", + UserID: 2, + Provider: "lakala", + MerchantID: "merchant-missing-config", + ThirdOrderID: "REF-RETRY-FAIL", + AmountCent: 1000, + BizType: "admin_refund", + Status: "refunding", + UpdatedAt: now.Add(-10 * time.Minute), + RetryCount: 1, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create refund payment: %v", err) + } + + processed, err := job.syncRefundPayments(t.Context(), now) + if err != nil { + t.Fatalf("syncRefundPayments() error = %v", err) + } + if processed != 0 { + t.Fatalf("processed = %d, want 0 when channel config is unavailable", processed) + } + var latest model.PaymentOrder + if err := db.First(&latest, row.ID).Error; err != nil { + t.Fatalf("find refund payment: %v", err) + } + if latest.RetryCount != 2 { + t.Fatalf("retry_count = %d, want 2", latest.RetryCount) + } + if latest.LastRetryAt == nil || !latest.LastRetryAt.Equal(now) { + t.Fatalf("last_retry_at = %v, want %v", latest.LastRetryAt, now) + } + if latest.NextRetryAt == nil || !latest.NextRetryAt.After(now) { + t.Fatalf("next_retry_at = %v, want a future retry time", latest.NextRetryAt) + } +} + // createArbitrationOrphanFixture 构造一个仲裁孤儿订单:已支付原单 + 仲裁已结案但退款单缺失。 func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder { t.Helper() diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index 8d803b8..f8fa1da 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "strings" + "sync" "testing" "time" @@ -694,6 +696,75 @@ func TestConfirmPaidRollsBackOrderWhenPaymentUpdateFails(t *testing.T) { } } +func TestConcurrentPaidNotificationsAreIdempotent(t *testing.T) { + db := database.NewTestDBWithName("payment-concurrent") + if err := database.MigrateRentalTransactionTestSchema(db); err != nil { + t.Fatalf("数据库迁移失败: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sqlite db: %v", err) + } + sqlDB.SetMaxOpenConns(8) + orderRepo := ordermodule.NewRepository(db) + repo := NewRepository(db, nil, orderRepo) + fixture := createPayableOrderFixture(t, db, "CONCURRENT", true) + params := map[string]string{ + "third_order_id": fixture.Payment.ThirdOrderID, + "status": "paid", + "amount": "1000", + "provider_order_id": "PROVIDER-CONCURRENT", + } + + const callers = 8 + results := make(chan error, callers) + var group sync.WaitGroup + for i := 0; i < callers; i++ { + group.Add(1) + go func() { + defer group.Done() + var callErr error + for attempt := 0; attempt < 50; attempt++ { + _, callErr = repo.HandleNotify(t.Context(), "mock", params, "status=paid", "application/x-www-form-urlencoded", "") + if callErr == nil || !strings.Contains(callErr.Error(), "locked") { + break + } + time.Sleep(5 * time.Millisecond) + } + results <- callErr + }() + } + group.Wait() + close(results) + for callErr := range results { + if callErr != nil { + t.Fatalf("concurrent HandleNotify() error = %v", callErr) + } + } + + var latestPayment model.PaymentOrder + if err := db.First(&latestPayment, fixture.Payment.ID).Error; err != nil { + t.Fatalf("find payment: %v", err) + } + if latestPayment.Status != "paid" { + t.Fatalf("payment status = %q, want paid", latestPayment.Status) + } + var latestOrder model.RentalOrder + if err := db.First(&latestOrder, fixture.Order.ID).Error; err != nil { + t.Fatalf("find order: %v", err) + } + if latestOrder.Status != "pending_handoff" || latestOrder.HandoffStatus != "pending_owner" { + t.Fatalf("order status = %q/%q, want pending_handoff/pending_owner", latestOrder.Status, latestOrder.HandoffStatus) + } + var paidCount int64 + if err := db.Model(&model.PaymentOrder{}).Where("id = ? AND status = 'paid'", fixture.Payment.ID).Count(&paidCount).Error; err != nil { + t.Fatalf("count paid payment: %v", err) + } + if paidCount != 1 { + t.Fatalf("paid payment rows = %d, want 1", paidCount) + } +} + // TestQueryClosedPaymentReturnsLocalStatus 验证关闭状态不再依赖渠道配置或继续轮询渠道。 func TestQueryClosedPaymentReturnsLocalStatus(t *testing.T) { db := setupPaymentTestDB(t) diff --git a/backend/internal/modules/withdrawal/repository_integration_test.go b/backend/internal/modules/withdrawal/repository_integration_test.go new file mode 100644 index 0000000..98bd2b0 --- /dev/null +++ b/backend/internal/modules/withdrawal/repository_integration_test.go @@ -0,0 +1,176 @@ +package withdrawal + +import ( + "context" + "errors" + "fmt" + "testing" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/wallet" + "hfb_sys/backend/pkg/crypto" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type withdrawalFixture struct { + db *gorm.DB + repo *Repository + userID uint64 + adminID uint64 + accountID uint64 +} + +func newWithdrawalFixture(t *testing.T) withdrawalFixture { + t.Helper() + dsn := fmt.Sprintf("file:withdrawal-%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.User{}, + &model.AdminUser{}, + &model.UserPaymentAccount{}, + &model.WithdrawalRequest{}, + &model.WalletAccount{}, + &model.WalletLedger{}, + ); err != nil { + t.Fatalf("migrate fixture: %v", err) + } + + user := model.User{Phone: "13800000001", Nickname: "test-user", Status: "active"} + admin := model.AdminUser{Username: "reviewer", PasswordHash: "test", Nickname: "reviewer", Status: "active"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + if err := db.Create(&admin).Error; err != nil { + t.Fatalf("create admin: %v", err) + } + account := model.UserPaymentAccount{ + UserID: user.ID, AccountType: "bank", AccountName: "Test User", + AccountNo: "6222021234567890", BankName: "Test Bank", Status: "active", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create payment account: %v", err) + } + if err := db.Create(&model.WalletAccount{ + UserID: user.ID, AvailableBalanceCent: 100_000, Status: "active", + }).Error; err != nil { + t.Fatalf("create wallet: %v", err) + } + + return withdrawalFixture{ + db: db, repo: NewRepository(db, wallet.NewRepository(db), &crypto.MockEncryptor{}), + userID: user.ID, adminID: admin.ID, accountID: account.ID, + } +} + +func (f withdrawalFixture) create(t *testing.T, amountCent int64) *WithdrawalDTO { + t.Helper() + dto, err := f.repo.Create(context.Background(), f.userID, CreateWithdrawalRequest{ + PaymentAccountID: f.accountID, + AmountCent: amountCent, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + return dto +} + +func (f withdrawalFixture) assertBalance(t *testing.T, available, frozen int64) { + t.Helper() + var account model.WalletAccount + if err := f.db.Where("user_id = ?", f.userID).First(&account).Error; err != nil { + t.Fatalf("load wallet: %v", err) + } + if account.AvailableBalanceCent != available || account.FrozenBalanceCent != frozen { + t.Fatalf("balance = available:%d frozen:%d, want available:%d frozen:%d", + account.AvailableBalanceCent, account.FrozenBalanceCent, available, frozen) + } +} + +func TestWithdrawalCreateAndCancelPreservesBalance(t *testing.T) { + f := newWithdrawalFixture(t) + dto := f.create(t, 20_000) + if dto.Status != "pending" || dto.FeeCent != 0 || dto.ActualAmountCent != 20_000 { + t.Fatalf("created withdrawal = %+v", dto) + } + f.assertBalance(t, 80_000, 20_000) + + if err := f.repo.Cancel(context.Background(), f.userID, dto.ID); err != nil { + t.Fatalf("Cancel: %v", err) + } + f.assertBalance(t, 100_000, 0) + if err := f.repo.Cancel(context.Background(), f.userID, dto.ID); !errors.Is(err, ErrWithdrawalLocked) { + t.Fatalf("second Cancel error = %v, want ErrWithdrawalLocked", err) + } + f.assertBalance(t, 100_000, 0) +} + +func TestWithdrawalRejectedReturnsFrozenBalanceOnce(t *testing.T) { + f := newWithdrawalFixture(t) + dto := f.create(t, 30_000) + approved := false + result, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{ + Approved: &approved, + Remark: "account mismatch", + }) + if err != nil { + t.Fatalf("Review rejected: %v", err) + } + if result.Status != "rejected" || result.ReviewedByName != "reviewer" { + t.Fatalf("review result = %+v", result) + } + f.assertBalance(t, 100_000, 0) + if _, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{Approved: &approved}); !errors.Is(err, ErrWithdrawalLocked) { + t.Fatalf("second Review error = %v, want ErrWithdrawalLocked", err) + } +} + +func TestWithdrawalApprovedAndPaidConsumesFrozenBalanceOnce(t *testing.T) { + f := newWithdrawalFixture(t) + dto := f.create(t, 40_000) + approved := true + if _, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{Approved: &approved}); err != nil { + t.Fatalf("Review approved: %v", err) + } + f.assertBalance(t, 60_000, 40_000) + + result, err := f.repo.ConfirmPayment(context.Background(), f.adminID, dto.ID, ConfirmPaymentRequest{ + PaymentProofURL: "proof/test.png", + Remark: "paid", + }) + if err != nil { + t.Fatalf("ConfirmPayment: %v", err) + } + if result.Status != "completed" || result.PaidByName != "reviewer" { + t.Fatalf("payment result = %+v", result) + } + f.assertBalance(t, 60_000, 0) + if _, err := f.repo.ConfirmPayment(context.Background(), f.adminID, dto.ID, ConfirmPaymentRequest{}); !errors.Is(err, ErrWithdrawalLocked) { + t.Fatalf("second ConfirmPayment error = %v, want ErrWithdrawalLocked", err) + } + f.assertBalance(t, 60_000, 0) +} + +func TestWithdrawalCreateRollsBackWhenBalanceIsInsufficient(t *testing.T) { + f := newWithdrawalFixture(t) + _, err := f.repo.Create(context.Background(), f.userID, CreateWithdrawalRequest{ + PaymentAccountID: f.accountID, + AmountCent: 100_001, + }) + if !errors.Is(err, wallet.ErrInsufficientBalance) { + t.Fatalf("Create error = %v, want ErrInsufficientBalance", err) + } + var count int64 + if err := f.db.Model(&model.WithdrawalRequest{}).Count(&count).Error; err != nil { + t.Fatalf("count withdrawals: %v", err) + } + if count != 0 { + t.Fatalf("withdrawal count = %d, want 0", count) + } + f.assertBalance(t, 100_000, 0) +} diff --git a/backend/internal/router/admin_routes.go b/backend/internal/router/admin_routes.go new file mode 100644 index 0000000..dea53bd --- /dev/null +++ b/backend/internal/router/admin_routes.go @@ -0,0 +1,97 @@ +package router + +import ( + "hfb_sys/backend/internal/modules/adminmgr" + "hfb_sys/backend/internal/modules/adminrole" + "hfb_sys/backend/internal/modules/announcement" + "hfb_sys/backend/internal/modules/chat" + "hfb_sys/backend/internal/modules/mohong" + "hfb_sys/backend/internal/modules/payment" + "hfb_sys/backend/internal/modules/supportgroup" + + "github.com/gin-gonic/gin" +) + +type permissionMiddleware func(string) gin.HandlerFunc + +func registerCrashAdminRoutes(routes *gin.RouterGroup, requirePerm permissionMiddleware, mohongHandler *mohong.Handler, paymentHandler *payment.Handler) { + if mohongHandler == nil { + return + } + register := func(prefix string) { + routes.GET(prefix+"/categories", requirePerm("mohong:product_view"), mohongHandler.AdminListCategories) + routes.POST(prefix+"/categories", requirePerm("mohong:category"), mohongHandler.AdminCreateCategory) + routes.PUT(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminUpdateCategory) + routes.DELETE(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminDeleteCategory) + routes.GET(prefix+"/products", requirePerm("mohong:product_view"), mohongHandler.AdminListProducts) + routes.GET(prefix+"/products/:id", requirePerm("mohong:product_view"), mohongHandler.AdminGetProduct) + routes.POST(prefix+"/products", requirePerm("mohong:product_manage"), mohongHandler.AdminCreateProduct) + routes.PUT(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminUpdateProduct) + routes.DELETE(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminDeleteProduct) + routes.GET(prefix+"/orders", requirePerm("mohong:order_view"), mohongHandler.AdminListOrders) + routes.GET(prefix+"/orders/:id", requirePerm("mohong:order_view"), mohongHandler.AdminGetOrder) + routes.POST(prefix+"/orders/:id/receive", requirePerm("mohong:order_manage"), mohongHandler.AdminReceiveOrder) + routes.POST(prefix+"/orders/:id/complete", requirePerm("mohong:order_manage"), mohongHandler.AdminCompleteOrder) + routes.POST(prefix+"/orders/:id/cancel", requirePerm("mohong:order_manage"), mohongHandler.AdminCancelOrder) + routes.POST(prefix+"/orders/:id/refund", requirePerm("mohong:order_manage"), paymentHandler.AdminRefundMohong) + routes.GET(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminGetConfig) + routes.PUT(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminUpdateConfig) + } + register("/crash") + register("/mohong") +} + +type adminManagementHandlers struct { + chat *chat.Handler + supportGroup *supportgroup.Handler + role *adminrole.Handler + admin *adminmgr.Handler + announcement *announcement.Handler +} + +func registerAdminManagementRoutes(routes *gin.RouterGroup, requirePerm permissionMiddleware, handlers adminManagementHandlers) { + routes.POST("/chats/qrcodes", requirePerm("qrcode:manage"), handlers.chat.CreateQrCodeHandler) + routes.POST("/chats/qrcodes/batch", requirePerm("qrcode:manage"), handlers.chat.BatchCreateQrCodeHandler) + routes.POST("/chats/qrcodes/batch-delete", requirePerm("qrcode:manage"), handlers.chat.BatchDeleteQrCodeHandler) + routes.GET("/chats/qrcodes", requirePerm("qrcode:view"), handlers.chat.ListQrCodesHandler) + routes.GET("/chats/qrcodes/stats", requirePerm("qrcode:view"), handlers.chat.GetQrCodeStatsHandler) + routes.POST("/chats/qrcodes/ocr-group-name", requirePerm("qrcode:manage"), handlers.chat.RecognizeQrCodeGroupNameHandler) + routes.POST("/chats/qrcodes/ocr-group-name/async", requirePerm("qrcode:manage"), handlers.chat.SubmitOCRJobHandler) + routes.GET("/chats/qrcodes/ocr-group-name/async/:jobId", requirePerm("qrcode:manage"), handlers.chat.GetOCRJobResultHandler) + routes.PATCH("/chats/qrcodes/:id", requirePerm("qrcode:manage"), handlers.chat.UpdateQrCodeHandler) + routes.DELETE("/chats/qrcodes/:id", requirePerm("qrcode:manage"), handlers.chat.DeleteQrCodeHandler) + + routes.GET("/chat-support-groups", requirePerm("chat:manage"), handlers.supportGroup.List) + routes.GET("/chat-support-groups/support-admins", requirePerm("chat:manage"), handlers.supportGroup.ListSupportAdmins) + routes.GET("/chat-support-groups/:id", requirePerm("chat:manage"), handlers.supportGroup.FindByID) + routes.POST("/chat-support-groups", requirePerm("chat:manage"), handlers.supportGroup.Create) + routes.PUT("/chat-support-groups/:id", requirePerm("chat:manage"), handlers.supportGroup.Update) + routes.DELETE("/chat-support-groups/:id", requirePerm("chat:manage"), handlers.supportGroup.Delete) + routes.PUT("/chat-support-groups/:id/members", requirePerm("chat:manage"), handlers.supportGroup.AssignMembers) + + routes.GET("/roles", requirePerm("role:manage"), handlers.role.List) + routes.GET("/roles/:id", requirePerm("role:manage"), handlers.role.FindByID) + routes.POST("/roles", requirePerm("role:manage"), handlers.role.Create) + routes.PUT("/roles/:id", requirePerm("role:manage"), handlers.role.Update) + routes.DELETE("/roles/:id", requirePerm("role:manage"), handlers.role.Delete) + routes.PUT("/roles/:id/permissions", requirePerm("role:manage"), handlers.role.AssignPermissions) + routes.GET("/permissions", requirePerm("role:manage"), handlers.role.ListPermissions) + + routes.GET("/admin-users", requirePerm("admin_user:manage"), handlers.admin.List) + routes.GET("/admin-users/:id", requirePerm("admin_user:manage"), handlers.admin.FindByID) + routes.POST("/admin-users", requirePerm("admin_user:manage"), handlers.admin.Create) + routes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), handlers.admin.Update) + routes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), handlers.admin.Delete) + routes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), handlers.admin.AssignRoles) + routes.PUT("/admin-users/me/password", handlers.admin.ChangeOwnPassword) + routes.PUT("/admin-users/:id/password", requirePerm("admin_user:manage"), handlers.admin.ResetPassword) + + routes.GET("/announcements", requirePerm("announcement:view"), handlers.announcement.AdminList) + routes.GET("/announcements/:id", requirePerm("announcement:view"), handlers.announcement.AdminGetByID) + routes.POST("/announcements", requirePerm("announcement:manage"), handlers.announcement.Create) + routes.PUT("/announcements/:id", requirePerm("announcement:manage"), handlers.announcement.Update) + routes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), handlers.announcement.Publish) + routes.POST("/announcements/:id/archive", requirePerm("announcement:manage"), handlers.announcement.Archive) + routes.POST("/announcements/:id/unarchive", requirePerm("announcement:manage"), handlers.announcement.Unarchive) + routes.DELETE("/announcements/:id", requirePerm("announcement:manage"), handlers.announcement.Delete) +} diff --git a/backend/internal/router/health_checks.go b/backend/internal/router/health_checks.go new file mode 100644 index 0000000..b368bf0 --- /dev/null +++ b/backend/internal/router/health_checks.go @@ -0,0 +1,29 @@ +package router + +import ( + "context" + "errors" + + "hfb_sys/backend/internal/handler" +) + +func newReadinessChecks(deps Dependencies) map[string]handler.ReadinessCheck { + return map[string]handler.ReadinessCheck{ + "mysql": func(ctx context.Context) error { + if deps.DB == nil { + return errors.New("mysql unavailable") + } + sqlDB, err := deps.DB.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) + }, + "redis": func(ctx context.Context) error { + if deps.Redis == nil { + return errors.New("redis unavailable") + } + return deps.Redis.Ping(ctx).Err() + }, + } +} diff --git a/backend/internal/router/providers.go b/backend/internal/router/providers.go new file mode 100644 index 0000000..e81f0f5 --- /dev/null +++ b/backend/internal/router/providers.go @@ -0,0 +1,48 @@ +package router + +import ( + "strings" + + "hfb_sys/backend/internal/config" + smsintegration "hfb_sys/backend/internal/integrations/sms" + "hfb_sys/backend/internal/modules/realname" + "hfb_sys/backend/pkg/crypto" + + "go.uber.org/zap" +) + +func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provider { + switch strings.ToLower(strings.TrimSpace(cfg.SMS.Provider)) { + case "aliyun": + provider, err := smsintegration.NewAliyunProvider(smsintegration.AliyunConfig{ + AccessKeyID: cfg.SMS.AliyunAccessKeyID, + AccessKeySecret: cfg.SMS.AliyunAccessKeySecret, + Endpoint: cfg.SMS.AliyunEndpoint, + SignName: cfg.SMS.AliyunSignName, + LoginTemplateCode: cfg.SMS.AliyunLoginTemplateCode, + }, logger) + if err != nil { + logger.Warn("阿里云短信服务初始化失败", zap.Error(err)) + return smsintegration.NewUnavailableProvider(err) + } + return provider + default: + return smsintegration.NewMockProvider(logger) + } +} + +func newRealnameProvider(cfg config.Config, encryptor crypto.Encryptor, logger *zap.Logger) realname.Provider { + switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) { + case "cloudmarket", "aliyun_cloudmarket": + provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{ + URL: cfg.Realname.CloudMarketURL, AppCode: cfg.Realname.CloudMarketAppCode, Encryptor: encryptor, + }) + if err != nil { + logger.Warn("实名认证服务初始化失败", zap.Error(err)) + return realname.NewUnavailableProvider(err) + } + return provider + default: + return realname.NewMockProvider(encryptor) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index aea3188..21d8804 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -2,11 +2,9 @@ package router import ( "context" - "strings" "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/handler" - smsintegration "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/modules/adminaudit" "hfb_sys/backend/internal/modules/adminauth" @@ -62,8 +60,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { engine.Use(middleware.Recovery(logger)) jwtManager := auth.NewJWTManager(cfg.JWTSecret) - health := handler.NewHealthHandler() - engine.GET("/health", health.Check) + health := handler.NewHealthHandler(newReadinessChecks(deps)) + engine.GET("/health", health.Live) + engine.GET("/health/ready", health.Ready) // Swagger 文档路由(仅在非生产环境) if !config.IsProductionEnv(cfg.AppEnv) { @@ -399,7 +398,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api := engine.Group("/api") { - api.GET("/health", health.Check) + api.GET("/health", health.Ready) api.GET("/listing-publish-options", systemConfigHandler.PublishOptions) api.GET("/listing-publish-agreements", systemConfigHandler.ListingPublishAgreements) api.GET("/listing-sale-price-config", systemConfigHandler.SalePriceConfig) @@ -653,31 +652,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline) adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal) - // 撞车后台(/admin/crash;兼容 /admin/mohong) - if mohongHandler != nil { - // 商品编辑需要拉分类列表,故 GET 用 product_view - registerCrashAdmin := func(prefix string) { - adminRoutes.GET(prefix+"/categories", requirePerm("mohong:product_view"), mohongHandler.AdminListCategories) - adminRoutes.POST(prefix+"/categories", requirePerm("mohong:category"), mohongHandler.AdminCreateCategory) - adminRoutes.PUT(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminUpdateCategory) - adminRoutes.DELETE(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminDeleteCategory) - adminRoutes.GET(prefix+"/products", requirePerm("mohong:product_view"), mohongHandler.AdminListProducts) - adminRoutes.GET(prefix+"/products/:id", requirePerm("mohong:product_view"), mohongHandler.AdminGetProduct) - adminRoutes.POST(prefix+"/products", requirePerm("mohong:product_manage"), mohongHandler.AdminCreateProduct) - adminRoutes.PUT(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminUpdateProduct) - adminRoutes.DELETE(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminDeleteProduct) - adminRoutes.GET(prefix+"/orders", requirePerm("mohong:order_view"), mohongHandler.AdminListOrders) - adminRoutes.GET(prefix+"/orders/:id", requirePerm("mohong:order_view"), mohongHandler.AdminGetOrder) - adminRoutes.POST(prefix+"/orders/:id/receive", requirePerm("mohong:order_manage"), mohongHandler.AdminReceiveOrder) - adminRoutes.POST(prefix+"/orders/:id/complete", requirePerm("mohong:order_manage"), mohongHandler.AdminCompleteOrder) - adminRoutes.POST(prefix+"/orders/:id/cancel", requirePerm("mohong:order_manage"), mohongHandler.AdminCancelOrder) - adminRoutes.POST(prefix+"/orders/:id/refund", requirePerm("mohong:order_manage"), paymentHandler.AdminRefundMohong) - adminRoutes.GET(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminGetConfig) - adminRoutes.PUT(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminUpdateConfig) - } - registerCrashAdmin("/crash") - registerCrashAdmin("/mohong") - } + registerCrashAdminRoutes(adminRoutes, requirePerm, mohongHandler, paymentHandler) adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList) adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate) @@ -750,95 +725,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/chats/listing-group-welcome", requirePerm("system_config:view"), chatHandler.AdminGetListingGroupWelcome) adminRoutes.PUT("/chats/listing-group-welcome", requirePerm("system_config:update"), chatHandler.AdminUpdateListingGroupWelcome) - // 二维码池管理 - adminRoutes.POST("/chats/qrcodes", requirePerm("qrcode:manage"), chatHandler.CreateQrCodeHandler) - adminRoutes.POST("/chats/qrcodes/batch", requirePerm("qrcode:manage"), chatHandler.BatchCreateQrCodeHandler) - adminRoutes.POST("/chats/qrcodes/batch-delete", requirePerm("qrcode:manage"), chatHandler.BatchDeleteQrCodeHandler) - adminRoutes.GET("/chats/qrcodes", requirePerm("qrcode:view"), chatHandler.ListQrCodesHandler) - adminRoutes.GET("/chats/qrcodes/stats", requirePerm("qrcode:view"), chatHandler.GetQrCodeStatsHandler) - adminRoutes.POST("/chats/qrcodes/ocr-group-name", requirePerm("qrcode:manage"), chatHandler.RecognizeQrCodeGroupNameHandler) - adminRoutes.POST("/chats/qrcodes/ocr-group-name/async", requirePerm("qrcode:manage"), chatHandler.SubmitOCRJobHandler) - adminRoutes.GET("/chats/qrcodes/ocr-group-name/async/:jobId", requirePerm("qrcode:manage"), chatHandler.GetOCRJobResultHandler) - adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.UpdateQrCodeHandler) - adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.DeleteQrCodeHandler) - - // 客服分组 - adminRoutes.GET("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.List) - adminRoutes.GET("/chat-support-groups/support-admins", requirePerm("chat:manage"), supportGroupHandler.ListSupportAdmins) - adminRoutes.GET("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.FindByID) - adminRoutes.POST("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.Create) - adminRoutes.PUT("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Update) - adminRoutes.DELETE("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Delete) - adminRoutes.PUT("/chat-support-groups/:id/members", requirePerm("chat:manage"), supportGroupHandler.AssignMembers) - - // 角色管理 - adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List) - adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID) - adminRoutes.POST("/roles", requirePerm("role:manage"), adminRoleHandler.Create) - adminRoutes.PUT("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Update) - adminRoutes.DELETE("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Delete) - adminRoutes.PUT("/roles/:id/permissions", requirePerm("role:manage"), adminRoleHandler.AssignPermissions) - adminRoutes.GET("/permissions", requirePerm("role:manage"), adminRoleHandler.ListPermissions) - - // 管理员管理 - adminRoutes.GET("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.List) - adminRoutes.GET("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.FindByID) - adminRoutes.POST("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.Create) - adminRoutes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Update) - adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete) - adminRoutes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), adminMgrHandler.AssignRoles) - adminRoutes.PUT("/admin-users/me/password", adminMgrHandler.ChangeOwnPassword) - adminRoutes.PUT("/admin-users/:id/password", requirePerm("admin_user:manage"), adminMgrHandler.ResetPassword) - - // 公告管理 - adminRoutes.GET("/announcements", requirePerm("announcement:view"), announcementHandler.AdminList) - adminRoutes.GET("/announcements/:id", requirePerm("announcement:view"), announcementHandler.AdminGetByID) - adminRoutes.POST("/announcements", requirePerm("announcement:manage"), announcementHandler.Create) - adminRoutes.PUT("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Update) - adminRoutes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), announcementHandler.Publish) - adminRoutes.POST("/announcements/:id/archive", requirePerm("announcement:manage"), announcementHandler.Archive) - adminRoutes.POST("/announcements/:id/unarchive", requirePerm("announcement:manage"), announcementHandler.Unarchive) - adminRoutes.DELETE("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Delete) + registerAdminManagementRoutes(adminRoutes, requirePerm, adminManagementHandlers{ + chat: chatHandler, supportGroup: supportGroupHandler, role: adminRoleHandler, + admin: adminMgrHandler, announcement: announcementHandler, + }) } } return engine } - -func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provider { - switch strings.ToLower(strings.TrimSpace(cfg.SMS.Provider)) { - case "aliyun": - provider, err := smsintegration.NewAliyunProvider(smsintegration.AliyunConfig{ - AccessKeyID: cfg.SMS.AliyunAccessKeyID, - AccessKeySecret: cfg.SMS.AliyunAccessKeySecret, - Endpoint: cfg.SMS.AliyunEndpoint, - SignName: cfg.SMS.AliyunSignName, - LoginTemplateCode: cfg.SMS.AliyunLoginTemplateCode, - }, logger) - if err != nil { - logger.Warn("阿里云短信服务初始化失败", zap.Error(err)) - return smsintegration.NewUnavailableProvider(err) - } - return provider - default: - return smsintegration.NewMockProvider(logger) - } -} - -func newRealnameProvider(cfg config.Config, encryptor crypto.Encryptor, logger *zap.Logger) realname.Provider { - switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) { - case "cloudmarket", "aliyun_cloudmarket": - provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{ - URL: cfg.Realname.CloudMarketURL, - AppCode: cfg.Realname.CloudMarketAppCode, - Encryptor: encryptor, - }) - if err != nil { - logger.Warn("实名认证服务初始化失败", zap.Error(err)) - return realname.NewUnavailableProvider(err) - } - return provider - default: - return realname.NewMockProvider(encryptor) - } -} diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go new file mode 100644 index 0000000..4b6e5f0 --- /dev/null +++ b/backend/internal/router/router_test.go @@ -0,0 +1,68 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "hfb_sys/backend/internal/config" + + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func testConfig() config.Config { + return config.Config{AppEnv: "test", JWTSecret: "test-secret"} +} + +func TestHealthRoutesSeparateLivenessAndReadiness(t *testing.T) { + engine := New(testConfig(), Dependencies{}, zap.NewNop()) + + for _, tt := range []struct { + path string + wantStatus int + }{ + {path: "/health", wantStatus: http.StatusOK}, + {path: "/health/ready", wantStatus: http.StatusServiceUnavailable}, + {path: "/api/health", wantStatus: http.StatusServiceUnavailable}, + } { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, tt.path, nil) + engine.ServeHTTP(recorder, request) + if recorder.Code != tt.wantStatus { + t.Fatalf("GET %s status = %d, want %d; body=%s", tt.path, recorder.Code, tt.wantStatus, recorder.Body.String()) + } + } +} + +func TestExtractedAdminRoutesRemainRegisteredWithoutDuplicates(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:router-routes?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + engine := New(testConfig(), Dependencies{DB: db}, zap.NewNop()) + routes := engine.Routes() + registered := make(map[string]struct{}, len(routes)) + for _, route := range routes { + key := route.Method + " " + route.Path + if _, exists := registered[key]; exists { + t.Fatalf("duplicate route: %s", key) + } + registered[key] = struct{}{} + } + + for _, key := range []string{ + "GET /api/admin/crash/products", + "GET /api/admin/mohong/products", + "POST /api/admin/chats/qrcodes/batch", + "PUT /api/admin/chat-support-groups/:id/members", + "PUT /api/admin/roles/:id/permissions", + "PUT /api/admin/admin-users/:id/password", + "POST /api/admin/announcements/:id/publish", + } { + if _, exists := registered[key]; !exists { + t.Errorf("missing route after extraction: %s", key) + } + } +} diff --git a/deploy/README.md b/deploy/README.md index addfa99..5f33128 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -216,6 +216,7 @@ docker compose -f deploy/docker-compose.prod.yml run --rm --no-deps backend \ ```bash curl https://主站域名/health +curl https://主站域名/health/ready curl https://主站域名/api/health # 若启用了选号网: curl -I https://选号网域名/ diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index dd0cada..4a8efe1 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -30,7 +30,7 @@ } } - handle /health { + handle /health /health/ready { reverse_proxy backend:8080 { import backend_proxy } diff --git a/deploy/caddy/Dockerfile b/deploy/caddy/Dockerfile index b058afb..02bd276 100644 --- a/deploy/caddy/Dockerfile +++ b/deploy/caddy/Dockerfile @@ -6,7 +6,7 @@ ENV VITE_ADMIN_BASE_PATH=${VITE_ADMIN_BASE_PATH} WORKDIR /src COPY frontend/package*.json ./ -RUN npm install +RUN npm ci COPY frontend/ . RUN npm run build diff --git a/docs/business-rules.md b/docs/business-rules.md new file mode 100644 index 0000000..d4b5a8a --- /dev/null +++ b/docs/business-rules.md @@ -0,0 +1,33 @@ +# 业务规则 + +## 商品 + +- 用户完成登录和实名认证后才能发布租赁商品。 +- 商品提交审核并通过后才可公开上架;处于交易中的商品不能被另一订单锁定。 +- 主动下架、交易离架、异常和封存是不同状态,必须保留状态事件。 + +## 订单 + +- 创建订单后进入待支付;支付成功后进入待交接并锁定商品。 +- 号主提交交接信息,租客确认后开始计算租期。 +- 租客归还后进入结账协商,双方确认或客服仲裁后完成结算。 +- 订单状态、交接状态和结算状态共同描述流程,禁止仅修改其中一个字段绕过状态机。 + +## 支付与退款 + +- 金额均使用整数分,支付单和退款单必须具有唯一业务单号。 +- 渠道通知必须先验签,再按幂等规则推进本地状态。 +- 已进入终态的支付或退款记录不能被迟到通知回退。 +- 人工退款、关闭订单、封存订单和仲裁退款的业务语义不同,均需审计记录。 + +## 钱包与提现 + +- 钱包区分可用余额和冻结余额,每次变更必须产生流水。 +- 创建提现申请时,可用余额转为冻结余额;取消或拒绝时原额返还。 +- 审核通过后只有确认打款才能最终扣减冻结余额,重复审核或重复确认必须被拒绝。 + +## 管理操作 + +- 管理后台使用独立认证和 RBAC 权限。 +- 关闭、退款、封存、异常、仲裁和余额调整等高风险操作必须写入审计日志。 +- 客服只能通过服务端返回的可用动作执行订单状态转换。 diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..20a822c --- /dev/null +++ b/docs/database.md @@ -0,0 +1,25 @@ +# 数据库设计 + +## 设计原则 + +- MySQL 8.4 是生产事实源,Redis 只保存限流、验证码、会话辅助和缓存数据。 +- 金额统一以 `*_cent` 的整数分存储;数量和比例字段按业务精度单独定义。 +- 核心写入通过 GORM 事务完成,钱包账户更新使用行锁并同步写入不可变流水。 +- 表结构由 `backend/migrations` 下的 Goose 迁移管理,应用启动不自动修改生产表结构。 + +## 核心领域 + +- 用户与权限:`users`、`admin_users`、角色、权限及关联表。 +- 商品:租赁商品、账号、资产截图、审核记录和状态事件。 +- 订单:租赁订单、交接记录、结账协商、过程事件和申诉。 +- 资金:支付单、退款单、钱包账户、钱包流水、收款账号和提现申请。 +- 运营:系统配置、公告、通知、聊天、二维码池、推送规则和撞车商品。 + +## 一致性约束 + +- 下单时在事务内锁定可租商品,避免重复出租。 +- 支付状态、订单状态和商品状态在同一业务事务内推进。 +- 钱包每次余额变化必须生成对应流水,提现在待审核阶段将可用余额转为冻结余额。 +- 支付通知和业务单号使用唯一索引实现幂等。 + +索引、慢查询、连接池和备份恢复细则见[数据库 SQL 约束与运维](数据库SQL约束与运维.md)。 diff --git a/docs/project-plan.md b/docs/project-plan.md new file mode 100644 index 0000000..2567f1d --- /dev/null +++ b/docs/project-plan.md @@ -0,0 +1,23 @@ +# 项目计划 + +## 产品目标 + +HFB Sys 面向《三角洲行动》账号租赁,提供从商品发布、审核、下单、支付、账号交接、租用、归还、结账到申诉仲裁的闭环,并支持平台代管账号和撞车商品交易。 + +## 当前阶段 + +系统已经进入生产运营与稳定性建设阶段。主流程、管理后台、支付渠道、财务统计、消息通知、对象存储和备份恢复均已落地,后续工作以资金正确性、自动化回归和运维可观测性为主。 + +## 迭代顺序 + +1. 资金链路:支付回调幂等、退款、钱包余额守恒、提现审核和结算测试。 +2. 稳定性:真实 MySQL E2E、就绪探针、超时任务和恢复演练。 +3. 可维护性:拆分大型前端页面和后端路由组装文件。 +4. 体验:根据运营数据优化商品筛选、订单操作和客服工作台。 + +## 发布标准 + +- `./scripts/check.sh` 全部通过。 +- 数据库迁移同时具备 Up/Down,并通过 SQL 门禁。 +- 涉及金额或状态机的变更必须包含回归测试。 +- 生产部署后 `/health/ready` 返回 200,关键下单、支付查询和后台登录完成冒烟验证。 diff --git a/docs/代码质量改进计划.md b/docs/代码质量改进计划.md index ca166ae..463b71e 100644 --- a/docs/代码质量改进计划.md +++ b/docs/代码质量改进计划.md @@ -1,643 +1,55 @@ # HFB Sys 代码质量改进计划 -## 文档说明 +## 当前基线 -本文档记录了项目代码审查发现的问题和优化计划。 +更新时间:2026-09-02。 -**生成时间**: 2026-06-10 -**项目版本**: refactor/features-architecture 分支 +- 后端约 374 个 Go 文件、6.6 万行代码,64 个测试文件。 +- 前端约 249 个 Vue/TypeScript 文件、8.4 万行代码,7 个测试文件、32 个测试;大型页面样式已外置,订单详情 setup 已独立为 composable 目录中的脚本模块。 +- 数据库使用 61 个 Goose 迁移文件,SQL 约束由 `scripts/check-sql-guard.sh` 检查。 +- 后端总语句覆盖率基线为 30%;核心模块门禁为 order 40%、payment 20%、wallet 34%、withdrawal 30%。 +- 前端支付、钱包、提现和订单展示目标文件已建立 V8 覆盖率门禁:语句 80%、分支 70%、函数 80%、行 80%。 +- Vue 页面大小门禁为 1800 行,`scripts/check-vue-size.sh` 在本地检查中执行,防止大型 SFC 回涨;订单详情页的指标、支付流水和展示工具已拆出独立模块。 +- Repository 已普遍传递请求 Context;当前约 376 处 GORM `WithContext` 调用。 ---- +## 已落地门禁 -## 📊 当前状态 +本地和部署前统一运行: -### 代码规模 -- **后端**: ~28,000 行 Go 代码,122 个模块文件 -- **前端**: ~46,000 行 Vue/TypeScript 代码 -- **数据库**: 876 行 SQL 初始化脚本,114 个索引 -- **测试覆盖率**: - - 后端约 10%(12 个测试文件) - - 前端接近 0%(仅 2 个测试文件) - -### 测试覆盖率详情 - -| 模块 | 覆盖率 | 状态 | -|------|--------|------| -| realname | 28.2% | ⚠️ 需改进 | -| listing | 11.1% | 🔴 严重不足 | -| wallet | 11.0% | 🔴 严重不足 | -| payment | 8.9% | 🔴 严重不足 | -| dispute | 8.8% | 🔴 严重不足 | -| order | 7.7% | 🔴 严重不足 | -| paymentconfig | 2.2% | 🔴 严重不足 | -| adminuser | 0.7% | 🔴 严重不足 | - ---- - -## 🔴 严重问题(需要尽快处理) - -### 1. 测试覆盖率严重不足 ⚠️ - -**现状**: -- 核心金融模块(payment, wallet, order)测试覆盖率低于 12% -- 复杂的订单状态机、结算逻辑、退款流程缺少测试保护 -- 前端几乎没有单元测试 - -**风险**: -- 金融相关的钱包、支付、退款逻辑出错会导致资金损失 -- 重构时容易引入 bug,没有安全网 -- 订单状态转换错误会导致业务流程异常 - -**改进计划**: -- ✅ **已完成** (2026-06-10 第一批): - - 为 wallet 模块补充 Service 层测试(8 个测试用例) - - 为 order 模块补充 Service 层测试(11 个测试用例) - - 为 payment 模块补充 Service 层测试(10 个测试用例) - -- ✅ **已完成** (2026-06-10 第二批): - - 为 wallet.Repository 补充核心业务逻辑测试(14 个逻辑测试 + 9 个集成测试) - - 为 order.Repository 补充测试(11 个集成测试) - - 为 payment.Repository 补充测试(18 个集成测试) - -- 🔄 **进行中**: - - [ ] 为 wallet.Repository 补充更多集成测试 - - [ ] 并发场景测试(账户锁定) - - [ ] Withdraw 提现测试 - - [ ] AdminLedger 管理员账本查询测试 - - - [ ] 为 order.Repository 补充更多集成测试 - - [ ] Pay 支付状态转换测试 - - [ ] SubmitHandoff/ConfirmReceive 交接流程测试 - - [ ] CounterCheckout 反价逻辑测试 - - - [ ] 为 payment.Repository 补充更多集成测试 - - [ ] Start 支付单创建与复用测试 - - [ ] StartRefund 退款流程测试 - - [ ] HandleNotify 支付回调处理测试 - -- 📅 **计划中**(1-2 周内完成): - - [ ] 前端关键组件测试 - - [ ] 支付组件单元测试 - - [ ] 钱包组件单元测试 - - [ ] 订单流程 E2E 测试 - - - [ ] 集成测试扩展 - - [ ] 完整租号流程测试(发布→下单→支付→交接→归还→结算) - - [ ] 退款流程测试 - - [ ] 仲裁流程测试 - -**目标**: -- 短期(1 个月内):核心模块测试覆盖率达到 **60%** -- 中期(3 个月内):整体测试覆盖率达到 **70%** -- 长期:建立 CI/CD 测试流程,强制测试覆盖率不低于 60% - ---- - -### 2. 缺少 Context 超时控制 🚨 - -**现状**: -- 122 个模块文件中只有 8 个使用 `context.Context` -- 数据库查询、HTTP 调用、外部 API(支付、短信、实名)都没有超时控制 - -**风险**: -- 数据库慢查询会导致 goroutine 泄漏 -- 外部 API 超时会拖垮整个服务 -- 无法实现请求级别的超时控制和取消机制 - -**改进计划**: -- 📅 **Week 1**: - - [ ] 为所有 Repository 方法签名添加 `ctx context.Context` 参数 - - [ ] 更新 Service 层传递 context - - [ ] 在 handler 层从 `c.Request.Context()` 获取 context - -- 📅 **Week 2**: - - [ ] 为数据库操作添加超时控制 - ```go - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - return r.db.WithContext(ctx).Create(...) - ``` - - - [ ] 为外部 API 调用添加超时控制 - - 支付渠道调用:10 秒超时 - - 短信服务:5 秒超时 - - 实名认证:10 秒超时 - -**示例代码**: -```go -// 修改前 -func (r *Repository) Create(userID uint64, req CreateRequest) (*OrderDTO, error) { - return r.db.Transaction(func(tx *gorm.DB) error { - // ... - }) -} - -// 修改后 -func (r *Repository) Create(ctx context.Context, userID uint64, req CreateRequest) (*OrderDTO, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // ... - }) -} +```bash +./scripts/check.sh ``` ---- +门禁包含: -### 3. 代码文件过大,职责不清 📦 +1. SQL 迁移约束与完整性检查。 +2. `go vet`、Go 全量构建、测试和覆盖率基线。 +3. Prettier、ESLint、Vue/TypeScript 类型检查。 +4. Vitest 和 Vite 生产构建。 -**现状**: -- `listing/repository.go`: **1731 行** -- `payment/repository.go`: **1282 行** -- `chat/repository.go`: **1155 行** -- 平均文件行数:**184 行**(偏高,建议 150 行以下) +本地门禁通过 `scripts/check.sh` 统一执行;需要完整租赁链路时,在本地 MySQL 8.4 上设置 `HFB_E2E_DSN` 运行 E2E。覆盖率门禁采用“只能提高、不能回退”的基线策略,新增测试后应同步上调阈值。 -**问题**: -- 单个文件包含太多逻辑,难以维护和测试 -- 违反单一职责原则 -- 代码复用困难 +## 后续重点 -**改进计划**: -- 📅 **Week 3-4**: - - **listing 模块拆分**: - ``` - listing/ - ├── repository.go # 核心仓储(保留 Create/Update/Delete) - ├── query.go # 查询逻辑(List/Detail/Search) - ├── review.go # 审核逻辑(SubmitReview/Approve/Reject) - └── cache.go # 缓存逻辑(PublicZoneCount) - ``` - - **payment 模块拆分**: - ``` - payment/ - ├── repository.go # 核心仓储 - ├── refund.go # 退款逻辑(StartRefund/QueryRefund) - ├── channel.go # 渠道适配(lakala/leshua/mock) - └── notify.go # 回调处理(HandleNotify) - ``` - - **chat 模块拆分**: - ``` - chat/ - ├── repository.go # 核心仓储 - ├── message.go # 消息逻辑(Send/List/MarkRead) - └── websocket.go # WebSocket 连接管理 - ``` +### P0:资金正确性 ---- +- 扩充支付通知乱序、重复回调和退款重试测试。 +- 扩充钱包并发入账、提现审核竞争和事务回滚测试。 +- 保持金额使用整数分存储,禁止业务层使用浮点数累计金额。 -## 🟡 重要问题(影响性能和可维护性) +### P1:前端回归保护 -### 4. 缺少接口抽象,模块耦合严重 +- 为支付收银台、订单操作、提现和后台财务组件增加测试。 +- 优先测试状态转换、权限可见性和请求参数,不追求低价值快照数量。 +- 逐步建立前端覆盖率基线。 -**现状**: -```go -// order/repository.go 直接依赖具体实现 -type Repository struct { - db *gorm.DB - chatRepo *chat.Repository // 直接依赖 - refundFunc RefundFunc // 通过函数注入 -} -``` +### P1:结构治理 -**问题**: -- 难以进行单元测试(无法 mock 依赖) -- 模块间耦合紧密,修改一个模块可能影响其他模块 -- 依赖注入通过 `SetXxx` 方法,容易忘记初始化 +- 将大型管理页面拆成展示组件、操作对话框和 composable。 +- 将路由组装按公开、用户、卖家、管理员领域拆分,保持 `router.New` 只负责依赖组装。 +- 单次拆分必须保持接口、路由名称和用户行为不变。 -**改进方案**: -```go -// 定义接口解耦 -type ChatService interface { - CreateOrderChat(ctx context.Context, orderID uint64) error -} +### P2:可观测性 -type RefundService interface { - Refund(ctx context.Context, orderID uint64, amount int64, bizType, remark string) error -} - -type Repository struct { - db *gorm.DB - chatSvc ChatService - refundSvc RefundService -} - -func NewRepository(db *gorm.DB, chatSvc ChatService, refundSvc RefundService) *Repository { - return &Repository{ - db: db, - chatSvc: chatSvc, - refundSvc: refundSvc, - } -} -``` - -**改进计划**: -- 📅 **Week 5-6**: - - [ ] 定义核心接口(ChatService, RefundService, WalletService) - - [ ] 重构 Repository 依赖注入 - - [ ] 更新 router 初始化代码 - ---- - -### 5. 数据库查询存在性能隐患 - -**现状**: -- 数据库索引定义相对较少(114 个索引,20+ 张表) -- 部分复杂查询缺少索引覆盖 -- 缺少慢查询监控和分析 - -**具体问题**: -```sql --- adminfinance/repository.go 中的财务汇总查询 -SELECT ... FROM rental_orders AS ro -JOIN order_checkouts AS oc ON oc.order_id = ro.id -LEFT JOIN (...) AS w ON w.order_id = ro.id -- 子查询可能很慢 -WHERE ro.settled_at >= ? AND ro.settled_at <= ? -``` - -**改进计划**: -- 📅 **Week 7**: - - [ ] 审查所有复杂查询,使用 `EXPLAIN` 分析 - - [ ] 补充缺失的索引: - ```sql - -- 订单结算查询优化 - CREATE INDEX idx_rental_orders_settled_at_status - ON rental_orders(settled_at, settlement_status); - - -- 财务流水查询优化 - CREATE INDEX idx_wallet_ledger_user_created - ON wallet_ledger(user_id, created_at DESC); - - -- 支付订单查询优化 - CREATE INDEX idx_payment_orders_biz_status - ON payment_orders(biz_type, status, created_at DESC); - ``` - - - [ ] 对复杂统计查询考虑预计算方案 - - 每日财务汇总定时任务(凌晨 1 点) - - 用户钱包余额缓存(Redis) - - 订单统计数据缓存(5 分钟 TTL) - -- 📅 **Week 8**: - - [ ] 启用慢查询日志(记录 > 1 秒的查询) - - [ ] 建立慢查询分析流程 - - [ ] 优化 TOP 10 慢查询 - ---- - -### 6. 缺少 API 限流和熔断机制 - -**现状**: -- 只有短信验证码有简单的限流(`ErrCodeRateLimited`) -- **没有全局 API 限流** -- **没有熔断机制**保护外部依赖(支付渠道、短信服务) - -**风险**: -- 恶意攻击会拖垮服务 -- 外部服务故障会级联影响整个系统 -- 无法防止暴力破解 - -**改进计划**: -- 📅 **Week 9**: - - **添加全局限流中间件**: - ```go - import "github.com/ulule/limiter/v3" - - func RateLimitMiddleware() gin.HandlerFunc { - rate := limiter.Rate{ - Period: time.Minute, - Limit: 100, // 每分钟 100 次请求 - } - store := memory.NewStore() - middleware := mgin.NewMiddleware(limiter.New(store, rate)) - return middleware - } - ``` - - **添加熔断器保护外部服务**: - ```go - import "github.com/sony/gobreaker" - - type PaymentChannelWithBreaker struct { - channel channelClient - breaker *gobreaker.CircuitBreaker - } - - func NewPaymentChannel(channel channelClient) *PaymentChannelWithBreaker { - breaker := gobreaker.NewCircuitBreaker(gobreaker.Settings{ - Name: "payment_channel", - MaxRequests: 3, - Timeout: 10 * time.Second, - ReadyToTrip: func(counts gobreaker.Counts) bool { - failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) - return counts.Requests >= 3 && failureRatio >= 0.6 - }, - }) - return &PaymentChannelWithBreaker{channel: channel, breaker: breaker} - } - ``` - -- 📅 **限流策略**: - - 全局:100 req/min per IP - - 登录接口:5 req/min per IP - - 短信发送:1 req/min per phone - - 支付接口:10 req/min per user - - 文件上传:20 req/hour per user - ---- - -### 7. 前端错误处理不统一 - -**现状**: -- 4 个 Vue 文件中仍有 `console.log/error` 调试代码 -- 错误处理分散在各个组件中 -- 缺少全局错误拦截和统一提示 - -**改进计划**: -- 📅 **Week 10**: - - **统一 HTTP 错误处理**: - ```typescript - // shared/utils/http.ts - import { ElMessage } from 'element-plus' - - axios.interceptors.response.use( - response => response, - error => { - const message = error.response?.data?.message || '请求失败' - ElMessage.error(message) - - // 统一错误上报(生产环境) - if (import.meta.env.PROD) { - errorReporter.report({ - message, - stack: error.stack, - url: error.config?.url, - method: error.config?.method, - }) - } - - return Promise.reject(error) - } - ) - ``` - - **清理调试代码**: - - [ ] 移除所有 `console.log` 调试代码 - - [ ] 添加 ESLint 规则禁止 `console.log` - - [ ] 使用统一的日志工具(开发环境) - ---- - -## 🟢 改进建议(提升代码质量) - -### 8. 日志记录不完整 - -**现状**: -- 有基础日志框架(zap)和请求日志中间件 -- 但业务日志记录不充分 -- 缺少关键业务节点的日志追踪 - -**改进建议**: -```go -// 为关键业务操作添加结构化日志 -logger.Info("order paid successfully", - zap.Uint64("order_id", orderID), - zap.Uint64("user_id", userID), - zap.Int64("amount_cent", amount), - zap.String("payment_no", paymentNo), - zap.String("provider", provider), -) - -logger.Warn("refund failed", - zap.Uint64("order_id", orderID), - zap.Int64("amount_cent", amount), - zap.String("reason", reason), - zap.Error(err), -) -``` - -**补充日志点**: -- [ ] 订单支付成功/失败 -- [ ] 退款申请/成功/失败 -- [ ] 账户交接关键节点 -- [ ] 仲裁处理结果 -- [ ] 钱包余额变更(大额) -- [ ] 系统配置修改 - ---- - -### 9. 配置管理可以改进 - -**现状**: -- 配置都通过环境变量管理 -- 缺少配置验证 -- 部分配置硬编码在代码中 - -**改进方案**: -```go -// 添加配置验证 -func (c Config) Validate() error { - if c.JWTSecret == "change-me" { - return errors.New("JWT_SECRET must be set in production") - } - if c.AppEnv == "production" && c.SMS.Provider == "mock" { - return errors.New("SMS provider cannot be mock in production") - } - if c.AppEnv == "production" && c.Realname.Provider == "mock" { - return errors.New("Realname provider cannot be mock in production") - } - return nil -} - -// 在 main.go 中调用 -cfg := config.Load() -if err := cfg.Validate(); err != nil { - log.Fatal("配置验证失败:", err) -} -``` - -**支持配置文件**: -- [ ] 支持 `config.yaml` 作为环境变量补充 -- [ ] 支持多环境配置文件(dev/staging/prod) -- [ ] 敏感配置仍使用环境变量 - ---- - -### 10. 前端共享逻辑可以进一步抽象 - -**现状**: -- `shared/utils` 目录已经做了不错的抽象 -- 但部分工具函数文件过大(`listingDisplay.ts` 11KB、`pricing.ts` 11KB) - -**拆分建议**: -``` -shared/utils/ -├── listing/ -│ ├── display.ts # 展示相关 -│ ├── format.ts # 格式化函数 -│ └── filters.ts # 过滤逻辑 -├── pricing/ -│ ├── pricing.ts # 价格计算 -│ ├── discount.ts # 折扣计算 -│ └── commission.ts # 佣金计算 -└── ... -``` - ---- - -### 11. 缺少文档和注释 - -**现状**: -- 代码注释较少 -- 没有完整的架构文档 -- API 文档依赖 Swagger - -**改进计划**: -- [ ] 补充架构文档 - - [ ] 数据库设计文档(`database.md`) - - [ ] 订单状态机文档 - - [ ] 支付流程文档 - - [ ] 结算规则文档 - -- [ ] 补充开发者指南 - - [ ] 如何添加新功能模块 - - [ ] 如何编写测试 - - [ ] 如何部署到生产环境 - -- [ ] 生成并维护 API 文档 - - [ ] 完善 Swagger 注释 - - [ ] 自动生成 API 文档 - ---- - -### 12. 缺少监控和告警 - -**现状**: -- 有基础日志(按天切分) -- 有审计日志 -- **但没有性能监控、错误追踪、告警机制** - -**改进方案**: - -**集成监控工具**: -- Prometheus + Grafana(应用监控) -- Sentry(错误追踪) -- OpenTelemetry(链路追踪,可选) - -**关键指标**: -- API 响应时间(P95, P99) -- 错误率 -- 订单支付成功率 -- 数据库慢查询 -- 外部 API 调用成功率 - -**告警规则**: -- API 错误率 > 5% -- 支付成功率 < 95% -- 数据库慢查询 > 10 次/分钟 -- 外部服务调用失败率 > 10% - ---- - -## 📊 优先级和时间计划 - -### 🔥 立即处理(已完成) -- ✅ 为核心金融模块补充单元测试(wallet, order, payment Service 层) - -### ⚡ 短期优化(1-2 周) -- **Week 1-2**: 添加 Context 超时控制 -- **Week 3-4**: 拆分大文件(listing/payment/chat repository) -- **Week 5-6**: 引入接口抽象,降低模块耦合 -- **Week 7-8**: 补充数据库索引,优化慢查询 - -### 🎯 中期改进(1-2 个月) -- **Week 9**: 添加 API 限流和熔断机制 -- **Week 10**: 统一前端错误处理 -- **Week 11-12**: 补充 Repository 层集成测试 -- **Week 13-14**: 补充前端组件测试 -- **Week 15-16**: 添加监控和告警系统 - -### 🌟 长期目标(3-6 个月) -- 建立 CI/CD 测试流程 -- 测试覆盖率达到 70%+ -- 完善技术文档 -- 性能优化(P95 响应时间 < 200ms) - ---- - -## 📈 成功指标 - -### 代码质量 -- [ ] 测试覆盖率 ≥ 60%(核心模块 ≥ 70%) -- [ ] 平均文件行数 < 150 行 -- [ ] 代码重复率 < 5% - -### 性能指标 -- [ ] API P95 响应时间 < 200ms -- [ ] API P99 响应时间 < 500ms -- [ ] 数据库慢查询 < 5 次/分钟 - -### 可靠性指标 -- [ ] API 可用性 > 99.9% -- [ ] 错误率 < 0.1% -- [ ] 支付成功率 > 99% - ---- - -## 🎉 已完成的改进 - -### 2026-06-10(第一批) -- ✅ 为 `wallet` 模块补充 Service 层单元测试(8 个测试用例) -- ✅ 为 `order` 模块补充 Service 层单元测试(11 个测试用例) -- ✅ 为 `payment` 模块补充 Service 层单元测试(10 个测试用例) -- ✅ 为 `wallet` 模块补充 Repository 逻辑测试(14 个测试用例) -- ✅ 为 `wallet` 模块补充 Repository 集成测试(9 个测试用例) -- ✅ 所有新增测试通过验证 -- ✅ 测试覆盖率基线建立: - - wallet: 11.0% → **36.1%**(提升 25%) - - order: 7.7% - - payment: 8.9% - -### 2026-06-10(第二批 - Week 1-2 完成) -- ✅ 为 `order` 模块补充 Repository 集成测试(11 个测试用例) - - 订单创建验证(商品状态、不能租自己的商品) - - 订单定价计算测试 - - 结账结算计算测试(全额租用、扣押金、额外消耗) - - 状态常量验证测试 - - 订单时长计算测试 -- ✅ 为 `payment` 模块补充 Repository 集成测试(18 个测试用例) - - 支付单复用逻辑测试 - - 支付状态转换测试 - - 退款业务类型覆盖测试 - - 支付金额验证测试 - - 支付渠道验证测试 - - Mock 模式判断测试 - - 钱包充值开关测试 -- ✅ 测试覆盖率提升: - - wallet: **36.1%**(保持) - - order: 7.7% → **10.2%**(提升 2.5%) - - payment: **8.9%**(保持) - -### 测试统计(Week 1-2 完成后) -- **测试文件总数**: 16 个 -- **测试用例总数**: 103 个(估算) - - wallet: 34 个 - - order: 28 个(17 + 11) - - payment: 41 个(23 + 18) -- **所有测试通过率**: 100% - ---- - -## 📝 备注 - -- 本文档是活文档,随着项目演进持续更新 -- 每完成一项改进,更新进度标记(✅) -- 定期回顾(每 2 周),调整优先级 - -**文档维护者**: Claude Code -**最后更新**: 2026-06-10 +- 区分 `/health` 存活探针和 `/health/ready` 就绪探针。 +- 持续观察慢查询、退款重试、订单超时任务和备份恢复演练结果。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b784352..691e0c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@types/qrcode": "^1.5.6", "@vant/auto-import-resolver": "^1.3.0", "@vitejs/plugin-vue": "^6.0.2", + "@vitest/coverage-v8": "^4.1.11", "@vue/tsconfig": "^0.8.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", @@ -92,30 +93,30 @@ "license": "MIT" }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -125,18 +126,28 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1801,17 +1812,48 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1820,13 +1862,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1857,9 +1899,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1870,13 +1912,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1884,14 +1926,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1900,9 +1942,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1910,13 +1952,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2252,6 +2294,35 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -3262,6 +3333,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3320,6 +3401,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3411,6 +3499,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -3586,6 +3713,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "18.0.5", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", @@ -4306,6 +4461,19 @@ "node": ">=16" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4749,19 +4917,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4789,12 +4957,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index 49bf970..c7ac654 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "format:check": "prettier --check .", "check": "npm run format:check && npm run lint:strict && npm run typecheck", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { @@ -33,6 +34,7 @@ "@types/qrcode": "^1.5.6", "@vant/auto-import-resolver": "^1.3.0", "@vitejs/plugin-vue": "^6.0.2", + "@vitest/coverage-v8": "^4.1.11", "@vue/tsconfig": "^0.8.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", diff --git a/frontend/src/features/admin/components/AdminOrderMetrics.css b/frontend/src/features/admin/components/AdminOrderMetrics.css new file mode 100644 index 0000000..f82fa52 --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderMetrics.css @@ -0,0 +1,18 @@ +.order-metrics { + margin-bottom: 20px; +} + +.metric-card strong { + overflow-wrap: anywhere; + font-size: 20px; + line-height: 1.25; +} + +.metric-card small { + display: block; + margin-top: 8px; + color: #8f9bba; + font-size: 12px; + line-height: 1.4; + word-break: break-word; +} diff --git a/frontend/src/features/admin/components/AdminOrderMetrics.vue b/frontend/src/features/admin/components/AdminOrderMetrics.vue new file mode 100644 index 0000000..b3ce1e6 --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderMetrics.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/features/admin/components/AdminOrderPaymentRecords.css b/frontend/src/features/admin/components/AdminOrderPaymentRecords.css new file mode 100644 index 0000000..d1751d4 --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderPaymentRecords.css @@ -0,0 +1,67 @@ +.panel-heading, +.record-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.panel-heading { + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 0; +} + +.panel-subtitle { + color: #8f9bba; + font-size: 13px; + line-height: 1.6; +} + +.record-list, +.record-item { + display: grid; + gap: 12px; +} + +.record-item { + gap: 8px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 14px; +} + +.record-main strong { + color: #1b2559; + font-size: 15px; +} + +.record-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 6px 12px; + color: #52616f; + font-size: 12px; + line-height: 1.5; +} + +.record-item p { + margin: 0; + color: #52616f; + line-height: 1.6; + white-space: pre-wrap; +} + +.danger-text { + color: #dc2626; +} + +@media (max-width: 640px) { + .panel-heading, + .record-main { + flex-direction: column; + } +} diff --git a/frontend/src/features/admin/components/AdminOrderPaymentRecords.vue b/frontend/src/features/admin/components/AdminOrderPaymentRecords.vue new file mode 100644 index 0000000..826da4a --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderPaymentRecords.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts b/frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts new file mode 100644 index 0000000..82ab90f --- /dev/null +++ b/frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { + estimateOrderEndAt, + paymentBizTypeLabel, + paymentPaidAt, + paymentStatusLabel, + paymentStatusType, +} from '../adminOrderPresentation' + +describe('admin order presentation', () => { + it('prefers the server-provided estimated end time', () => { + expect(estimateOrderEndAt('2026-09-03T12:00:00Z', '2026-09-02T12:00:00Z', 2)).toBe( + '2026-09-03T12:00:00Z' + ) + }) + + it('calculates the end time from rental start and duration', () => { + expect(estimateOrderEndAt(undefined, '2026-09-02T12:00:00Z', 2)).toBe( + '2026-09-02T14:00:00.000Z' + ) + expect(estimateOrderEndAt(undefined, 'invalid', 2)).toBeUndefined() + }) + + it('maps known payment states and preserves unknown channel values', () => { + expect(paymentStatusLabel('refunded')).toBe('已退款') + expect(paymentStatusType('failed')).toBe('danger') + expect(paymentBizTypeLabel('order_pay')).toBe('订单支付') + expect(paymentBizTypeLabel('future_type')).toBe('future_type') + }) + + it('selects the most meaningful payment completion time', () => { + expect( + paymentPaidAt({ + paid_at: 'paid', + updated_at: 'updated', + created_at: 'created', + }) + ).toBe('paid') + expect(paymentPaidAt({ updated_at: 'updated', created_at: 'created' })).toBe('updated') + }) +}) diff --git a/frontend/src/features/admin/utils/adminOrderPresentation.ts b/frontend/src/features/admin/utils/adminOrderPresentation.ts new file mode 100644 index 0000000..779dab4 --- /dev/null +++ b/frontend/src/features/admin/utils/adminOrderPresentation.ts @@ -0,0 +1,51 @@ +import type { AdminPayment } from '@/features/admin/api/adminPayments' + +export function estimateOrderEndAt( + estimatedEndAt?: string, + rentedAt?: string, + durationHours?: number +) { + if (estimatedEndAt) return estimatedEndAt + const hours = Number(durationHours || 0) + if (!rentedAt || hours <= 0) return undefined + const startedAt = new Date(rentedAt).getTime() + if (!Number.isFinite(startedAt)) return undefined + return new Date(startedAt + hours * 60 * 60 * 1000).toISOString() +} + +export function paymentStatusLabel(status: string) { + const labels: Record = { + created: '已创建', + paying: '支付中', + paid: '已支付', + refunding: '退款中', + refunded: '已退款', + failed: '失败', + } + return labels[status] || status +} + +export function paymentStatusType(status: string) { + if (['paid', 'refunded'].includes(status)) return 'success' as const + if (status === 'failed') return 'danger' as const + if (['paying', 'refunding'].includes(status)) return 'warning' as const + return 'info' as const +} + +export function paymentBizTypeLabel(type: string) { + const labels: Record = { + order_pay: '订单支付', + checkout_refund: '结账退款', + arbitration_refund: '仲裁退款', + admin_refund: '人工退款', + cancel_refund: '取消退款', + admin_close_refund: '客服关闭退款', + admin_seal_refund: '封存退款', + deposit_refund: '暂扣押金归还', + } + return labels[type] || type +} + +export function paymentPaidAt(record: Pick) { + return record.paid_at || record.updated_at || record.created_at +} diff --git a/frontend/src/features/admin/views/AdminChatsView.css b/frontend/src/features/admin/views/AdminChatsView.css new file mode 100644 index 0000000..9b898cd --- /dev/null +++ b/frontend/src/features/admin/views/AdminChatsView.css @@ -0,0 +1,560 @@ +.admin-page { + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); +} + +.page-head { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.page-head h1 { + margin: 0; + font-size: 22px; +} + +.page-head p { + margin: 6px 0 0; + color: #6b7280; +} + +.head-right { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.chat-workbench { + display: grid; + min-height: 0; + grid-template-columns: 330px minmax(0, 1fr); + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.conversation-pane { + min-height: 0; + overflow-y: auto; + border-right: 1px solid #e5e7eb; + background: #f8fafc; +} + +.filter-tabs { + display: grid; + gap: 10px; + padding: 10px 12px 12px; + border-bottom: 1px solid #e5e7eb; +} + +.filter-tabs :deep(.el-radio-group) { + display: flex; + width: 100%; +} + +.filter-tabs :deep(.el-radio-button) { + flex: 1; +} + +.filter-tabs :deep(.el-radio-button__inner) { + width: 100%; + padding: 7px 8px; +} + +.stage-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.stage-tabs button { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 8px; + border: 1px solid #d8dee8; + border-radius: 999px; + background: #fff; + color: #4b5563; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.stage-tabs button.active { + border-color: #3b82f6; + background: #eff6ff; + color: #2563eb; +} + +.stage-tabs em { + color: #94a3b8; + font-style: normal; + font-weight: 800; +} + +.stage-tabs button.active em { + color: #2563eb; +} + +.conversation-search { + width: 100%; +} + +.conversation-pagination { + display: flex; + justify-content: center; + padding: 12px 8px; + border-top: 1px solid #e5e7eb; + background: #fff; +} + +.conversation-row { + position: relative; + display: block; + width: 100%; + padding: 14px; + border: 0; + border-bottom: 1px solid #e5e7eb; + background: transparent; + text-align: left; +} + +.conversation-row.active { + background: #eef6ff; +} + +.row-title { + display: flex; + gap: 10px; + align-items: center; +} + +.row-title strong { + min-width: 0; + flex: 1; + overflow: hidden; + color: #111827; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-title span { + flex: none; + color: #9ca3af; + font-size: 12px; +} + +.conversation-row p { + margin: 8px 24px 0 0; + overflow: hidden; + color: #6b7280; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-tags { + display: flex; + min-width: 0; + gap: 6px; + align-items: center; + margin-top: 8px; +} + +.row-tags span { + overflow: hidden; + max-width: 160px; + padding: 2px 7px; + border-radius: 999px; + background: #eef2f7; + color: #64748b; + font-size: 11px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-tags .stage-tag { + flex: none; + max-width: none; + font-weight: 700; +} + +.stage-tag.pending { + background: #fee2e2; + color: #dc2626; +} + +.stage-tag.unjoined { + background: #fef3c7; + color: #b45309; +} + +.stage-tag.handoff { + background: #dbeafe; + color: #2563eb; +} + +.stage-tag.renting { + background: #dcfce7; + color: #15803d; +} + +.stage-tag.after-sale { + background: #f3e8ff; + color: #7e22ce; +} + +.stage-tag.ended { + background: #e5e7eb; + color: #4b5563; +} + +.row-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.support-name { + color: #8a94a6; + font-size: 12px; +} + +.row-meta em { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ef4444; + color: #fff; + font-size: 11px; + font-style: normal; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +.message-pane { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.message-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid #e5e7eb; +} + +.head-title { + flex: 1; + min-width: 0; +} + +.head-title h2 { + margin: 0; + font-size: 18px; + display: flex; + align-items: center; + gap: 8px; +} + +.head-title p { + margin: 6px 0 0; + color: #6b7280; + font-size: 13px; +} + +.head-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.head-actions a { + text-decoration: none; +} + +.message-body { + display: grid; + min-width: 0; + min-height: 0; + background: #f3f6fa; +} + +.message-body.has-order { + grid-template-columns: minmax(0, 1fr) minmax(330px, 38%); +} + +.message-list { + min-height: 0; + overflow-y: auto; + padding: 18px; + background: #f3f6fa; +} + +.order-side-panel { + min-width: 0; + overflow-y: auto; + padding: 16px; + border-left: 1px solid #e5e7eb; + background: #f8fafc; +} + +.order-side-head, +.order-side-section { + padding: 14px; + border: 1px solid #edf2f7; + border-radius: 8px; + background: #fff; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.03); +} + +.order-side-head { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +.order-side-head span { + color: #2563eb; + font-size: 12px; + font-weight: 700; +} + +.order-side-head strong { + color: #071947; + font-size: 18px; +} + +.order-side-head p, +.order-side-section p { + margin: 0; + color: #7c8aaa; + font-size: 13px; + line-height: 1.6; +} + +.order-side-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.order-side-metrics div { + display: grid; + gap: 5px; + min-width: 0; + padding: 12px; + border: 1px solid #edf2f7; + border-radius: 8px; + background: #fff; +} + +.order-side-metrics span { + color: #8a94a6; + font-size: 12px; +} + +.order-side-metrics strong { + overflow: hidden; + color: #071947; + font-size: 16px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.order-side-section { + display: grid; + gap: 8px; +} + +.order-side-section + .order-side-section { + margin-top: 12px; +} + +.order-side-section h3 { + margin: 0; + color: #071947; + font-size: 15px; +} + +.order-side-section strong { + color: #071947; + font-size: 13px; +} + +.message-row { + max-width: 70%; + margin-bottom: 14px; +} + +.message-row.self { + margin-left: auto; + text-align: right; +} + +.message-row.system { + max-width: none; + text-align: center; +} + +.system-block { + display: inline-flex; + max-width: min(100%, 320px); + flex-direction: column; + align-items: center; + gap: 8px; +} + +.system-attachments { + display: grid; + justify-items: center; + gap: 6px; +} + +.message-row small { + display: block; + margin-bottom: 5px; + color: #8a94a6; +} + +.read-status { + margin-top: 4px; + margin-bottom: 0; + font-size: 11px; +} + +.read-status.read { + color: #67c23a; +} + +.message-row p { + display: inline-block; + margin: 0; + padding: 10px 12px; + border-radius: 8px; + background: #fff; + color: #111827; + line-height: 1.5; + text-align: left; +} + +.message-row.self p { + background: #dff5eb; +} + +.message-row.admin p { + background: #fff4e6; + border-left: 3px solid #ff9800; +} + +.admin-label { + color: #ff9800 !important; + font-weight: 600; +} + +.message-attachments { + display: grid; + justify-items: start; + gap: 6px; +} + +.message-row.self .message-attachments { + justify-items: end; +} + +.message-row.system span { + display: inline-block; + padding: 5px 10px; + border-radius: 8px; + background: #e5e7eb; + color: #6b7280; + font-size: 12px; +} + +.composer { + border-top: 1px solid #e5e7eb; +} + +.composer-tools { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 14px; + border-bottom: 1px solid #f0f0f0; +} + +.hidden-file { + display: none; +} + +.pending-attachments { + display: flex; + gap: 8px; + padding: 10px 14px 0; + overflow-x: auto; +} + +.pending-item { + position: relative; + flex: none; +} + +.pending-item :deep(.chat-image-button) { + width: 84px; + height: 84px; +} + +.pending-item :deep(.chat-image-button img) { + height: 84px; +} + +.remove-attachment { + position: absolute; + top: 4px; + right: 4px; + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 0; + border-radius: 50%; + background: rgba(17, 24, 39, 0.72); + color: #fff; + cursor: pointer; +} + +.composer-input { + display: grid; + grid-template-columns: minmax(0, 1fr) 88px; + gap: 12px; + align-items: end; + padding: 14px; +} + +.reply-title { + font-weight: 500; + margin-right: 8px; +} + +.reply-preview { + color: #9ca3af; + font-size: 12px; +} diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index 79f211f..270c78b 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -990,565 +990,4 @@ function firstQueryValue(value: unknown) {
- + diff --git a/frontend/src/features/admin/views/AdminDashboardView.css b/frontend/src/features/admin/views/AdminDashboardView.css new file mode 100644 index 0000000..20db491 --- /dev/null +++ b/frontend/src/features/admin/views/AdminDashboardView.css @@ -0,0 +1,882 @@ +.finance-page-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 0 4px 32px; +} + +/* ================= 头部卡片 ================= */ +.page-header-card { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #3b82f6; + background: #eff6ff; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 状态卡片 ================= */ +.state-card { + background: #ffffff; + border-radius: 16px; + padding: 48px 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + border: 1px solid rgba(226, 232, 240, 0.8); +} + +.loading-state { + color: #64748b; +} + +.loading-state .el-icon { + font-size: 36px; + color: #3b82f6; +} + +.error-state { + color: #e11d48; +} + +.error-state .el-icon { + font-size: 36px; +} + +/* ================= 核心指标卡片 ================= */ +.summary-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 16px; +} + +.metric-card { + background: #ffffff; + border-radius: 16px; + padding: 20px 22px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + align-items: center; + gap: 16px; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.metric-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.metric-card--users::before { + background: linear-gradient(90deg, #6366f1, #818cf8); +} + +.metric-card--listings::before { + background: linear-gradient(90deg, #10b981, #34d399); +} + +.metric-card--orders::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.metric-card--flow::before { + background: linear-gradient(90deg, #f43f5e, #fb7185); +} + +.metric-icon-wrap { + width: 48px; + height: 48px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + flex-shrink: 0; +} + +.metric-icon-wrap.indigo { + background: #eef2ff; + color: #6366f1; +} + +.metric-icon-wrap.emerald { + background: #ecfdf5; + color: #10b981; +} + +.metric-icon-wrap.amber { + background: #fffbeb; + color: #f59e0b; +} + +.metric-icon-wrap.rose { + background: #fff1f2; + color: #f43f5e; +} + +.metric-icon-wrap.blue { + background: #eff6ff; + color: #3b82f6; +} + +.metric-icon-wrap.teal { + background: #f0fdfa; + color: #0d9488; +} + +.metric-icon-wrap.slate { + background: #f1f5f9; + color: #64748b; +} + +.metric-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 13px; + font-weight: 500; + color: #64748b; +} + +.metric-val { + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; + line-height: 1.2; +} + +.indigo-val { + color: #4f46e5; +} + +.emerald-val { + color: #059669; +} + +.amber-val { + color: #d97706; +} + +.rose-val { + color: #e11d48; +} + +.metric-sub-pill { + display: inline-flex; + align-items: center; + font-size: 11.5px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + width: fit-content; +} + +.metric-sub-pill.info { + background: #eef2ff; + color: #4f46e5; +} + +.metric-sub-pill.success { + background: #f0fdf4; + color: #16a34a; +} + +.metric-sub-pill.warning { + background: #fffbeb; + color: #d97706; +} + +.metric-sub-pill.danger { + background: #fff1f2; + color: #e11d48; +} + +/* ================= 通用 Section 卡片 ================= */ +.section-card { + background: #ffffff; + border-radius: 16px; + padding: 22px 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.section-header-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.section-header-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 17px; + font-weight: 700; + color: #1e293b; +} + +.header-icon { + color: #3b82f6; + font-size: 20px; +} + +.header-icon.rose-icon { + color: #f43f5e; +} + +.header-badge { + font-size: 12px; + font-weight: 500; + color: #64748b; + background: #f1f5f9; + padding: 3px 10px; + border-radius: 12px; +} + +/* ================= 上下架 3 组 Mini-Bars ================= */ +.listing-daily-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.listing-stat-card { + padding: 20px; + border-radius: 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + transition: all 0.2s ease; +} + +.listing-stat-card:hover { + background: #ffffff; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); + border-color: #cbd5e1; +} + +.listing-stat-head { + display: flex; + align-items: center; + gap: 12px; +} + +.listing-stat-icon { + width: 40px; + height: 40px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} + +.tone-publish .listing-stat-icon { + background: #ecfdf5; + color: #10b981; +} + +.tone-offline .listing-stat-icon { + background: #fffbeb; + color: #f59e0b; +} + +.tone-trade .listing-stat-icon { + background: #eff6ff; + color: #3b82f6; +} + +.listing-stat-meta span { + display: block; + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.listing-stat-meta strong { + display: block; + font-size: 24px; + font-weight: 800; + color: #0f172a; + line-height: 1.1; + margin-top: 2px; +} + +.listing-stat-hint { + margin: 10px 0 16px; + font-size: 11.5px; + color: #94a3b8; + line-height: 1.4; +} + +.mini-bars { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 8px; + align-items: end; + min-height: 90px; + padding-top: 8px; + border-top: 1px dashed #e2e8f0; +} + +.mini-bar-col { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 0; +} + +.mini-bar { + width: 100%; + max-width: 20px; + border-radius: 4px 4px 1px 1px; + min-height: 6px; + transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.mini-bar.publish { + background: linear-gradient(180deg, #34d399, #10b981); +} + +.mini-bar.offline { + background: linear-gradient(180deg, #fbbf24, #f59e0b); +} + +.mini-bar.trade { + background: linear-gradient(180deg, #60a5fa, #3b82f6); +} + +.mini-bar-col em { + font-style: normal; + font-size: 10px; + color: #94a3b8; +} + +.mini-bar-col b { + font-size: 11px; + color: #1e293b; + font-weight: 700; +} + +/* ================= 渠道总览 ================= */ +.channel-summary-title { + font-size: 14px; + font-weight: 700; + color: #334155; + margin: 16px 0 10px; +} + +.listing-channel-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.listing-channel-item { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 14px; + transition: all 0.2s ease; +} + +.listing-channel-item:hover { + background: #ffffff; + border-color: #cbd5e1; + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05); +} + +.listing-channel-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.channel-name { + font-size: 13.5px; + font-weight: 700; + color: #1e293b; +} + +.channel-total { + font-size: 18px; + font-weight: 800; + color: #3b82f6; +} + +.listing-channel-counts { + display: flex; + flex-direction: column; + gap: 4px; +} + +.count-tag { + font-size: 11.5px; + font-weight: 600; +} + +.count-tag.publish { + color: #059669; +} + +.count-tag.offline { + color: #d97706; +} + +.count-tag.trade { + color: #2563eb; +} + +.trend-table-wrap { + margin-bottom: 20px; +} + +.listing-channel-block { + margin-top: 20px; +} + +.listing-uploader-block { + margin-top: 20px; +} + +.listing-uploader-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.listing-uploader-item { + display: flex; + align-items: baseline; + gap: 8px; + border: 1px solid #dbeafe; + border-radius: 10px; + background: #eff6ff; + padding: 14px; +} + +.uploader-name { + overflow: hidden; + color: #1e3a8a; + font-size: 13.5px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uploader-total { + margin-left: auto; + color: #2563eb; + font-size: 20px; + font-weight: 800; +} + +.uploader-unit { + color: #64748b; + font-size: 11.5px; + white-space: nowrap; +} + +.channel-badge { + font-weight: 600; +} + +.trend-val { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 700; + font-size: 12.5px; +} + +.text-emerald { + color: #059669; +} +.text-amber { + color: #d97706; +} +.text-blue { + color: #2563eb; +} +.text-slate { + color: #475569; +} + +/* ================= 待处理事项 & 快捷入口 ================= */ +.dashboard-panels-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.panel-card { + display: flex; + flex-direction: column; +} + +.panel-header-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.panel-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.panel-icon { + font-size: 18px; +} + +.amber-icon { + color: #f59e0b; +} + +.blue-icon { + color: #3b82f6; +} + +.pending-total-badge { + font-size: 12px; + font-weight: 600; + color: #64748b; + background: #f1f5f9; + padding: 3px 10px; + border-radius: 20px; +} + +.pending-total-badge.has-pending { + color: #e11d48; + background: #fff1f2; +} + +.pending-list-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pending-item-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + text-decoration: none; + transition: all 0.2s ease; +} + +.pending-item-card:hover:not(.static-card) { + background: #ffffff; + border-color: #cbd5e1; + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05); + transform: translateX(2px); +} + +.pending-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pending-label { + font-size: 13.5px; + font-weight: 700; + color: #1e293b; +} + +.pending-desc { + font-size: 11.5px; + color: #94a3b8; +} + +.pending-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 8px; + border-radius: 14px; + font-size: 13px; + font-weight: 800; +} + +.badge-urgent { + background: #fff1f2; + color: #e11d48; + border: 1px solid #fecdd3; +} + +.badge-zero { + background: #f1f5f9; + color: #94a3b8; +} + +/* ================= 快捷入口 ================= */ +.quick-nav-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.quick-tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 18px 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 14px; + text-decoration: none; + transition: all 0.2s ease; +} + +.quick-tile:hover { + background: #ffffff; + border-color: #cbd5e1; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); + transform: translateY(-2px); +} + +.quick-icon-wrap { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.quick-icon-wrap.indigo { + background: #eef2ff; + color: #6366f1; +} + +.quick-icon-wrap.blue { + background: #eff6ff; + color: #3b82f6; +} + +.quick-icon-wrap.rose { + background: #fff1f2; + color: #f43f5e; +} + +.quick-icon-wrap.emerald { + background: #ecfdf5; + color: #10b981; +} + +.quick-icon-wrap.teal { + background: #f0fdfa; + color: #0d9488; +} + +.quick-icon-wrap.slate { + background: #f1f5f9; + color: #64748b; +} + +.quick-title { + font-size: 12.5px; + font-weight: 700; + color: #334155; +} + +/* ================= 表格容器与通用组件 ================= */ +.table-section-card { + padding-bottom: 16px; +} + +.table-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.table-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.view-all-link { + font-size: 12.5px; + font-weight: 600; + color: #3b82f6; + text-decoration: none; +} + +.view-all-link:hover { + text-decoration: underline; +} + +.table-container { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__body-wrapper td) { + padding: 12px 0; + font-size: 13px; + color: #334155; +} + +.order-link { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12.5px; + font-weight: 600; + color: #3b82f6; + text-decoration: none; +} + +.order-link:hover { + text-decoration: underline; +} + +.mono-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: #64748b; +} + +.status-tag { + font-weight: 600; +} + +.amount-val { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13.5px; + font-weight: 700; + color: #0f172a; +} + +.time-cell { + font-size: 12px; + color: #64748b; +} + +.dispute-type { + font-size: 12.5px; + color: #475569; +} + +@media (max-width: 1100px) { + .listing-daily-grid { + grid-template-columns: 1fr; + } + + .dashboard-panels-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .quick-nav-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/frontend/src/features/admin/views/AdminDashboardView.vue b/frontend/src/features/admin/views/AdminDashboardView.vue index dd1339d..38f1d46 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.vue +++ b/frontend/src/features/admin/views/AdminDashboardView.vue @@ -674,887 +674,4 @@ function shortDate(date: string) { - + diff --git a/frontend/src/features/admin/views/AdminDisputesView.css b/frontend/src/features/admin/views/AdminDisputesView.css new file mode 100644 index 0000000..6348763 --- /dev/null +++ b/frontend/src/features/admin/views/AdminDisputesView.css @@ -0,0 +1,263 @@ +.dispute-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.summary-item { + display: grid; + gap: 6px; + padding: 14px 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.summary-item span { + color: #64748b; + font-size: 13px; +} + +.summary-item strong { + color: #0f172a; + font-size: 24px; + line-height: 1; +} + +.filter-panel { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + padding: 12px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.filter-control { + width: 180px; +} + +.keyword-filter { + width: 280px; +} + +.arbitration-field { + display: grid; + gap: 6px; + margin-top: 12px; +} + +.arbitration-field span { + color: #475569; + font-size: 13px; + font-weight: 700; +} + +.main-cell, +.party-cell { + display: grid; + gap: 4px; +} + +.main-cell strong { + color: #0f172a; + font-weight: 700; +} + +.main-cell span, +.party-cell span { + color: #64748b; + font-size: 12px; + line-height: 1.4; +} + +.dispute-table :deep(.el-table__cell) { + vertical-align: top; +} + +.evidence-dialog { + display: grid; + gap: 14px; +} + +.evidence-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.evidence-header div { + display: grid; + gap: 5px; +} + +.evidence-header strong { + color: #0f172a; + font-size: 17px; +} + +.evidence-header span, +.evidence-meta-grid span, +.evidence-description span { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.evidence-meta-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.evidence-meta-grid > div { + display: grid; + gap: 5px; + min-width: 0; + padding: 10px 12px; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.evidence-meta-grid strong { + min-width: 0; + overflow: hidden; + color: #1f2937; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.evidence-description { + display: grid; + gap: 6px; + padding: 12px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.evidence-description p { + margin: 0; + color: #334155; + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; +} + +.evidence-list { + display: grid; + gap: 12px; + max-height: 420px; + padding-right: 4px; + overflow: auto; +} + +.evidence-card { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + gap: 14px; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; +} + +.evidence-preview { + display: flex; + align-items: center; + justify-content: center; + min-height: 96px; + overflow: hidden; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.evidence-file-icon { + display: flex; + align-items: center; + justify-content: center; + width: 58px; + height: 58px; + border-radius: 8px; + background: #eef2ff; + color: #4f46e5; + font-weight: 800; +} + +.evidence-card :deep(.evidence-image) { + display: block; + width: 100%; + height: 96px; + object-fit: cover; +} + +.evidence-card :deep(.evidence-image-fallback) { + min-height: 96px; + border: 0; +} + +.evidence-info { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; +} + +.evidence-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.evidence-title-row strong { + min-width: 0; + overflow: hidden; + color: #0f172a; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.evidence-info p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.evidence-actions { + display: flex; + gap: 8px; +} + +@media (max-width: 960px) { + .dispute-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filter-panel { + align-items: stretch; + flex-direction: column; + } + + .filter-control { + width: 100%; + } + + .evidence-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .evidence-card { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index 11d5d86..f1b71e0 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -676,268 +676,4 @@ async function handleArbitrate() { - + diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.css b/frontend/src/features/admin/views/AdminFinanceDashboardView.css new file mode 100644 index 0000000..b973f55 --- /dev/null +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.css @@ -0,0 +1,662 @@ +.finance-dashboard-page { + padding: 0 4px 32px; + display: flex; + flex-direction: column; + gap: 24px; +} + +/* ================= 头部 Header & 筛选栏 ================= */ +.dashboard-header { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); + display: flex; + flex-direction: column; + gap: 20px; +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #4f46e5; + background: #eef2ff; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.header-meta { + display: flex; + align-items: center; +} + +.meta-item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + color: #64748b; + background: #f8fafc; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid #e2e8f0; +} + +.filter-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + padding-top: 16px; + border-top: 1px solid #f1f5f9; +} + +.preset-buttons { + display: inline-flex; + background: #f1f5f9; + padding: 3px; + border-radius: 10px; + gap: 2px; +} + +.preset-btn { + border: none; + background: transparent; + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + color: #64748b; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.preset-btn:hover { + color: #1e293b; +} + +.preset-btn.active { + background: #ffffff; + color: #4f46e5; + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.date-picker-group { + display: flex; + align-items: center; + gap: 8px; +} + +.date-input { + width: 140px; +} + +.date-separator { + color: #94a3b8; + font-size: 13px; +} + +.action-buttons { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 核心 Hero KPI 卡片 ================= */ +.hero-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.hero-card { + background: #ffffff; + border-radius: 16px; + padding: 20px 22px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.hero-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.hero-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.hero-card--flow::before { + background: linear-gradient(90deg, #3b82f6, #60a5fa); +} + +.hero-card--profit::before { + background: linear-gradient(90deg, #10b981, #34d399); +} + +.hero-card--net::before { + background: linear-gradient(90deg, #06b6d4, #22d3ee); +} + +.hero-card--outflow::before { + background: linear-gradient(90deg, #8b5cf6, #a78bfa); +} + +.hero-card--risk::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.hero-card-header { + display: flex; + align-items: center; + gap: 10px; +} + +.hero-icon-box { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.hero-card--flow .hero-icon-box { + background: #eff6ff; + color: #3b82f6; +} + +.hero-card--profit .hero-icon-box { + background: #ecfdf5; + color: #10b981; +} + +.hero-card--net .hero-icon-box { + background: #ecfeff; + color: #06b6d4; +} + +.hero-card--outflow .hero-icon-box { + background: #f5f3ff; + color: #8b5cf6; +} + +.hero-card--risk .hero-icon-box { + background: #fffbeb; + color: #f59e0b; +} + +.hero-label { + font-size: 13.5px; + font-weight: 600; + color: #64748b; +} + +.hero-value { + font-size: 28px; + font-weight: 800; + color: #0f172a; + margin: 12px 0 10px; + letter-spacing: -0.5px; + line-height: 1.2; +} + +.diff-value.danger { + color: #ef4444; +} + +.diff-value.success { + color: #10b981; +} + +.hero-footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 12px; +} + +.hero-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + font-size: 11.5px; +} + +.hero-pill.success { + background: #f0fdf4; + color: #15803d; +} + +.hero-pill.emerald { + background: #ecfdf5; + color: #047857; +} + +.hero-pill.teal { + background: #f0fdfa; + color: #0f766e; +} + +.hero-pill.purple { + background: #f5f3ff; + color: #6d28d9; +} + +.hero-pill.warning { + background: #fffbeb; + color: #b45309; +} + +.hero-pill.danger { + background: #fef2f2; + color: #b91c1c; +} + +.hero-pill.neutral { + background: #f1f5f9; + color: #475569; +} + +.hero-note { + color: #94a3b8; + font-size: 11.5px; +} + +/* ================= 模块化 Section 卡片 ================= */ +.dashboard-sections { + display: flex; + flex-direction: column; + gap: 24px; +} + +.section-container { + background: #ffffff; + border-radius: 16px; + padding: 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.section-title-wrap { + display: flex; + align-items: center; + gap: 12px; +} + +.section-badge { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.section-badge.primary { + background: #eff6ff; + color: #3b82f6; +} + +.section-badge.teal { + background: #f0fdfa; + color: #0d9488; +} + +.section-badge.blue { + background: #eff6ff; + color: #2563eb; +} + +.section-badge.purple { + background: #f5f3ff; + color: #7c3aed; +} + +.section-badge.indigo { + background: #eef2ff; + color: #4f46e5; +} + +.section-title { + margin: 0; + font-size: 17px; + font-weight: 700; + color: #1e293b; +} + +.section-desc { + margin: 3px 0 0; + font-size: 12.5px; + color: #64748b; +} + +/* 子指标网格 */ +.sub-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; +} + +.sub-metric-grid.cols-2 { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.sub-metric-grid.cols-3 { + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.sub-card { + background: #f8fafc; + border-radius: 12px; + padding: 16px 18px; + border: 1px solid #f1f5f9; + display: flex; + flex-direction: column; + gap: 6px; + transition: all 0.2s ease; +} + +.sub-card:hover { + background: #f1f5f9; + border-color: #e2e8f0; +} + +.sub-card.highlight-pending { + background: #fffbeb; + border-color: #fef3c7; +} + +.sub-card-label { + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.sub-card-value { + font-size: 20px; + font-weight: 700; + color: #0f172a; + letter-spacing: -0.3px; +} + +.sub-card-sub { + font-size: 11.5px; + color: #64748b; + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.dot-sep { + color: #cbd5e1; +} + +.danger-text { + color: #ef4444; +} + +.warning-text { + color: #d97706; +} + +.success-text { + color: #10b981; +} + +.emerald-text { + color: #059669; +} + +.orange-text { + color: #ea580c; +} + +.primary-text { + color: #3b82f6; +} + +/* 出款区域两栏布局 */ +.disbursement-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 1024px) { + .disbursement-layout { + grid-template-columns: 1fr; + } +} + +.disbursement-subgroup { + display: flex; + flex-direction: column; + gap: 12px; +} + +.subgroup-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13.5px; + font-weight: 600; + color: #334155; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.success-dot { + background: #10b981; +} + +.warning-dot { + background: #f59e0b; +} + +/* 专项分部卡片 */ +.segments-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 1024px) { + .segments-row { + grid-template-columns: 1fr; + } +} + +/* ================= 每日明细表格美化 ================= */ +.table-container { + padding-bottom: 16px; +} + +.table-section-header { + margin-bottom: 16px; +} + +.record-badge { + font-size: 12px; + font-weight: 600; + color: #4f46e5; + background: #eef2ff; + padding: 4px 12px; + border-radius: 20px; +} + +.table-wrapper { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-finance-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-finance-table :deep(.el-table__body-wrapper td) { + padding: 14px 0; + font-size: 13px; + color: #334155; +} + +.date-cell { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + color: #1e293b; +} + +.amount-cell { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.profit-cell { + color: #059669; + font-weight: 700; +} + +.flow-cell { + color: #1d4ed8; +} + +.refund-cell { + color: #dc2626; +} + +.outflow-cell { + color: #7c3aed; +} + +.expense-cell { + color: #d97706; +} + +.count-pill { + display: inline-block; + min-width: 24px; + padding: 2px 8px; + border-radius: 12px; + background: #f1f5f9; + color: #475569; + font-size: 12px; + font-weight: 600; +} + +.count-pill.primary-pill { + background: #eef2ff; + color: #4f46e5; +} + +.refund-cancel-tag-group { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.mini-tag { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.mini-tag.refund-tag { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fee2e2; +} + +.mini-tag.cancel-tag { + background: #fffbeb; + color: #b45309; + border: 1px solid #fef3c7; +} + +.mini-tag.muted-tag { + background: #f8fafc; + color: #94a3b8; +} diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 166178d..488b47b 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -743,667 +743,4 @@ function diffType(value: number | undefined | null) { - + diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css new file mode 100644 index 0000000..a3962ec --- /dev/null +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css @@ -0,0 +1,545 @@ +.finance-page-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 0 4px 32px; +} + +/* ================= 头部卡片 ================= */ +.page-header-card { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #4f46e5; + background: #eef2ff; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 核心指标卡片 ================= */ +.summary-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.metric-card { + background: #ffffff; + border-radius: 16px; + padding: 18px 20px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + align-items: center; + gap: 16px; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.metric-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.metric-card--paid::before { + background: linear-gradient(90deg, #10b981, #34d399); +} + +.metric-card--pending::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.metric-card--review::before { + background: linear-gradient(90deg, #6366f1, #818cf8); +} + +.metric-card--fee::before { + background: linear-gradient(90deg, #06b6d4, #22d3ee); +} + +.metric-card--total::before { + background: linear-gradient(90deg, #64748b, #94a3b8); +} + +.metric-icon-wrap { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.metric-icon-wrap.emerald { + background: #ecfdf5; + color: #10b981; +} + +.metric-icon-wrap.amber { + background: #fffbeb; + color: #f59e0b; +} + +.metric-icon-wrap.indigo { + background: #eef2ff; + color: #6366f1; +} + +.metric-icon-wrap.teal { + background: #ecfeff; + color: #06b6d4; +} + +.metric-icon-wrap.slate { + background: #f1f5f9; + color: #64748b; +} + +.metric-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.metric-val { + font-size: 22px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.3px; + line-height: 1.2; +} + +.emerald-val { + color: #059669; +} + +.amber-val { + color: #d97706; +} + +.indigo-val { + color: #4f46e5; +} + +.teal-val { + color: #0891b2; +} + +.metric-sub-pill { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + width: fit-content; +} + +.metric-sub-pill.success { + background: #f0fdf4; + color: #16a34a; +} + +.metric-sub-pill.warning { + background: #fffbeb; + color: #d97706; +} + +.metric-sub-pill.info { + background: #eef2ff; + color: #4f46e5; +} + +.metric-sub-text { + font-size: 11.5px; + color: #94a3b8; +} + +/* ================= 筛选面板 ================= */ +.section-card { + background: #ffffff; + border-radius: 16px; + padding: 22px 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px 16px; +} + +.filter-grid :deep(.el-form-item) { + margin-bottom: 0; +} + +.filter-grid :deep(.el-form-item__label) { + padding-bottom: 4px; + font-size: 12px; + font-weight: 600; + color: #64748b; +} + +.full-control { + width: 100%; +} + +/* ================= 表格卡片 ================= */ +.table-section-card { + padding-bottom: 16px; +} + +.table-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.table-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.header-icon { + color: #4f46e5; + font-size: 18px; +} + +.count-badge { + font-size: 12px; + font-weight: 600; + color: #4f46e5; + background: #eef2ff; + padding: 3px 10px; + border-radius: 20px; +} + +.table-container { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__body-wrapper td) { + padding: 12px 0; + font-size: 13px; + color: #334155; +} + +.business-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.source-tag { + width: fit-content; + font-weight: 600; + font-size: 11px; +} + +.business-no { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: #1e293b; +} + +.link-no { + color: #3b82f6; + text-decoration: none; + font-weight: 600; +} + +.link-no:hover { + text-decoration: underline; +} + +.stack-cell { + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1.35; +} + +.payee-name { + font-weight: 600; + color: #0f172a; +} + +.phone-text { + font-size: 11.5px; + color: #64748b; +} + +.muted-note { + font-size: 11.5px; + color: #94a3b8; +} + +.channel-note { + font-size: 11px; + color: #64748b; +} + +.account-line { + display: flex; + align-items: center; + gap: 4px; + font-weight: 500; + font-size: 12.5px; +} + +.account-type-badge { + font-size: 10.5px; + padding: 1px 4px; + border-radius: 4px; + background: #f1f5f9; + color: #475569; +} + +.account-num { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11.5px; + color: #64748b; +} + +.bank-name { + font-size: 11.5px; + color: #94a3b8; +} + +.category-tag { + width: fit-content; + margin-bottom: 2px; +} + +.voucher-uploaded { + font-size: 11px; + color: #10b981; +} + +.voucher-missing { + font-size: 11px; + color: #94a3b8; +} + +.amount-stack { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.actual-amount { + font-size: 14.5px; + font-weight: 700; + color: #0f172a; +} + +.fee-detail { + font-size: 11px; + color: #94a3b8; +} + +.status-pill { + font-weight: 600; +} + +.time-cell { + font-size: 12px; + color: #475569; +} + +.empty-time { + color: #cbd5e1; +} + +.operator-name { + font-size: 12.5px; + color: #475569; +} + +.remark-text { + font-size: 12px; + color: #64748b; +} + +.action-cell { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +/* ================= 弹窗样式 ================= */ +.voucher-upload-box { + display: flex; + align-items: center; + gap: 16px; +} + +.voucher-preview-wrap { + width: 60px; + height: 60px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.voucher-img-preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-content-wrap { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13.5px; + border-bottom: 1px dashed #f1f5f9; + padding-bottom: 8px; +} + +.detail-row.column-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.detail-label { + color: #64748b; +} + +.detail-val { + color: #0f172a; +} + +.detail-img-container { + max-width: 100%; + max-height: 240px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.detail-voucher-img { + max-width: 100%; + max-height: 240px; + object-fit: contain; +} + +.void-info-box { + background: #fef2f2; + border-radius: 8px; + padding: 12px 14px; + border: 1px solid #fee2e2; + color: #b91c1c; + font-size: 12.5px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.void-title { + display: flex; + align-items: center; + gap: 6px; + font-weight: 700; + margin-bottom: 2px; +} diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue index 732b6e2..1c4e3d5 100644 --- a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue @@ -849,550 +849,4 @@ function accountTypeLabel(type: string) { - + diff --git a/frontend/src/features/admin/views/AdminListingDetailView.css b/frontend/src/features/admin/views/AdminListingDetailView.css new file mode 100644 index 0000000..3ddcc46 --- /dev/null +++ b/frontend/src/features/admin/views/AdminListingDetailView.css @@ -0,0 +1,489 @@ +.listing-detail-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 20px; + margin-bottom: 18px; +} + +.listing-title-block { + min-width: 0; +} + +.listing-code-line { + display: inline-flex; + align-items: center; + gap: 8px; + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.listing-code-line strong { + color: #2563eb; + letter-spacing: 0; +} + +.listing-title-block h1 { + margin: 8px 0 10px; + color: #0f172a; + font-size: 30px; + line-height: 1.15; +} + +.listing-subtitle { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.listing-subtitle span { + padding: 4px 9px; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #fff; + color: #64748b; + font-size: 13px; +} + +.listing-summary-bar { + display: grid; + grid-template-columns: repeat(6, minmax(120px, 1fr)); + gap: 1px; + overflow: hidden; + margin-bottom: 16px; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #e5e7eb; +} + +.listing-summary-bar > div { + display: grid; + min-height: 74px; + align-content: center; + gap: 8px; + padding: 12px 14px; + background: #fff; +} + +.listing-summary-bar span { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.listing-summary-bar strong { + color: #0f172a; + font-size: 21px; + line-height: 1.1; +} + +.listing-workspace { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(360px, 0.7fr); + gap: 16px; + align-items: start; +} + +.detail-panel { + min-width: 0; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #fff; + padding: 16px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); +} + +.screenshots-panel { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid #eef2f7; +} + +.panel-heading h2 { + margin: 0; + color: #0f172a; + font-size: 16px; + font-weight: 800; +} + +.panel-heading span { + overflow: hidden; + color: #94a3b8; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + margin: 0; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #eef2f7; +} + +.compact-detail-grid.single { + grid-template-columns: 1fr; +} + +.compact-detail-grid div { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 10px; + align-items: start; + min-height: 38px; + padding: 10px 12px; + background: #fff; +} + +.compact-detail-grid dt { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.compact-detail-grid dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: #0f172a; + font-size: 13px; + font-weight: 700; +} + +.resource-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.resource-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.resource-item span { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.resource-item strong { + color: #0f172a; + font-size: 18px; +} + +.skin-chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.screenshot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 12px; +} + +.screenshot-thumb { + display: grid; + gap: 8px; + padding: 8px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + color: #64748b; + cursor: pointer; + text-align: left; +} + +.screenshot-thumb:hover { + border-color: #93c5fd; + background: #eff6ff; +} + +.screenshot-thumb img { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 6px; + background: #f1f5f9; + object-fit: cover; +} + +.screenshot-image { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 6px; +} + +.screenshot-thumb span { + color: #334155; + font-size: 13px; + font-weight: 700; +} + +.empty-line { + margin: 0; + color: #94a3b8; + font-size: 13px; +} + +.transfer-owner-trigger { + min-width: 120px; + font-weight: 700; +} + +.external-adjust-form { + display: grid; + gap: 4px; +} + +:global(.external-adjust-dialog) { + max-width: calc(100vw - 32px); +} + +:global(.external-adjust-dialog .el-dialog__body) { + max-height: calc(100vh - 180px); + overflow-y: auto; +} + +.external-adjust-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 18px; +} + +.external-adjust-grid :deep(.el-input-number), +.external-adjust-form :deep(.el-input-number) { + width: 100%; +} + +.external-adjust-preview { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + margin: 4px 0 18px 112px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #e5e7eb; +} + +.external-adjust-preview > div { + display: grid; + min-height: 70px; + align-content: center; + gap: 7px; + padding: 10px; + background: #f8fafc; +} + +.external-adjust-preview span { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.external-adjust-preview strong { + color: #0f172a; + font-size: 16px; +} + +.transfer-dialog { + display: grid; + gap: 18px; +} + +.transfer-route { + display: grid; + grid-template-columns: minmax(0, 1fr) 32px minmax(0, 1fr); + align-items: stretch; + gap: 12px; + padding: 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.transfer-owner-card { + display: flex; + min-width: 0; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + color: #64748b; +} + +.transfer-owner-card.target { + border-color: #86efac; + background: #f0fdf4; +} + +.transfer-owner-card.target.empty { + border-color: #bfdbfe; + background: #eff6ff; +} + +.transfer-owner-card span { + font-size: 13px; +} + +.transfer-owner-card strong { + overflow: hidden; + color: #0f172a; + font-size: 18px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-owner-card small { + overflow: hidden; + color: #94a3b8; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-arrow { + display: grid; + place-items: center; + color: #2563eb; + font-size: 22px; + font-weight: 800; +} + +.transfer-section { + display: grid; + gap: 8px; +} + +.transfer-section label { + color: #334155; + font-size: 14px; + font-weight: 700; +} + +.transfer-user-list { + min-height: 138px; + max-height: 280px; + overflow: auto; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.transfer-user { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 13px 14px; + border: 0; + border-bottom: 1px solid #eef2f7; + background: #fff; + color: #334155; + text-align: left; + cursor: pointer; +} + +.transfer-user:last-child { + border-bottom: 0; +} + +.transfer-user:hover, +.transfer-user.is-selected { + background: #eff6ff; +} + +.transfer-user.is-selected { + box-shadow: inset 3px 0 0 #2563eb; +} + +.transfer-user-main { + min-width: 0; +} + +.transfer-user strong { + display: block; + overflow: hidden; + color: #0f172a; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-user small { + display: block; + margin-top: 4px; + overflow: hidden; + color: #94a3b8; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-user-meta { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 8px; +} + +@media (max-width: 760px) { + .listing-detail-header, + .listing-workspace { + grid-template-columns: 1fr; + } + + .listing-summary-bar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-detail-grid { + grid-template-columns: 1fr; + } + + .transfer-route { + grid-template-columns: 1fr; + } + + .external-adjust-grid, + .external-adjust-preview { + grid-template-columns: 1fr; + } + + .external-adjust-preview { + margin-left: 0; + } + + .transfer-arrow { + transform: rotate(90deg); + } + + .transfer-user { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/frontend/src/features/admin/views/AdminListingDetailView.vue b/frontend/src/features/admin/views/AdminListingDetailView.vue index e98b51b..63ca82f 100644 --- a/frontend/src/features/admin/views/AdminListingDetailView.vue +++ b/frontend/src/features/admin/views/AdminListingDetailView.vue @@ -1027,494 +1027,4 @@ async function openScreenshot(url: string) { - + diff --git a/frontend/src/features/admin/views/AdminListingReviewView.css b/frontend/src/features/admin/views/AdminListingReviewView.css new file mode 100644 index 0000000..20599a7 --- /dev/null +++ b/frontend/src/features/admin/views/AdminListingReviewView.css @@ -0,0 +1,590 @@ +.review-page { + min-height: calc(100vh - 128px); +} + +.review-header { + margin-bottom: 18px; +} + +.review-summary { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.summary-card { + min-width: 0; + border: 1px solid #e8edf4; + border-radius: 8px; + background: #ffffff; + padding: 14px 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + text-align: left; + cursor: pointer; + transition: all 0.2s ease; +} + +.summary-card:hover { + border-color: #1477ff; + box-shadow: 0 2px 8px rgba(20, 119, 255, 0.15); + transform: translateY(-1px); +} + +.summary-card.active { + border-color: #1477ff; + background: #1477ff; + box-shadow: 0 2px 8px rgba(20, 119, 255, 0.25); +} + +.summary-card.active span { + color: #ffffff; +} + +.summary-card.active strong { + color: #ffffff; +} + +.review-summary span, +.queue-footer span, +.panel-title span, +.inventory-grid span, +.resource-table span, +.price-decision-card span, +.adjust-current span, +.adjust-field span { + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.review-summary strong { + display: block; + margin-top: 5px; + color: #1b2559; + font-size: 24px; + font-weight: 800; +} + +.review-workbench { + display: grid; + grid-template-columns: minmax(360px, 420px) minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.review-queue, +.review-detail, +.review-panel, +.review-hero-panel { + min-width: 0; +} + +.review-queue { + position: sticky; + top: 16px; + border-radius: 8px; + background: #ffffff; + padding: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.queue-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) 120px; + gap: 10px; + margin-bottom: 12px; +} + +.queue-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: calc(100vh - 300px); + overflow: auto; + padding-right: 2px; +} + +.queue-item { + width: 100%; + min-height: 112px; + border: 1px solid #e8ecf1; + border-radius: 8px; + background: #ffffff; + padding: 12px; + text-align: left; + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s, + background 0.2s; +} + +.queue-item:hover, +.queue-item.active { + border-color: #4f7cff; + background: #f8faff; + box-shadow: 0 6px 18px rgba(79, 124, 255, 0.1); +} + +.queue-title-row, +.queue-footer, +.panel-title, +.hero-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.queue-title-row strong { + display: block; + min-width: 0; + color: #1b2559; + font-size: 14px; + font-weight: 800; + line-height: 1.4; +} + +.queue-title-row span { + flex-shrink: 0; + color: #4f7cff; + font-weight: 800; +} + +.queue-title-row .queue-code { + display: inline-flex; + margin-bottom: 4px; + padding: 2px 7px; + border-radius: 999px; + background: #eff6ff; + font-size: 12px; +} + +.queue-meta, +.risk-strip, +.skin-list, +.reject-reasons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.queue-meta { + margin: 10px 0; +} + +.queue-meta span { + border-radius: 6px; + background: #f0f2f5; + padding: 4px 7px; + color: #4b5563; + font-size: 12px; + font-weight: 700; +} + +.queue-footer { + align-items: center; +} + +.queue-tags, +.panel-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.review-detail { + display: flex; + flex-direction: column; + gap: 14px; +} + +.review-hero-panel, +.review-panel { + border-radius: 8px; + background: #ffffff; + padding: 18px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.hero-title h2, +.panel-title h3 { + margin: 0; + color: #1b2559; + font-size: 18px; + font-weight: 800; + line-height: 1.35; +} + +.hero-title p { + margin: 0 0 6px; + color: #8f9bba; + font-size: 12px; + font-weight: 700; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.risk-strip { + margin-top: 14px; +} + +.review-metrics { + grid-template-columns: repeat(5, minmax(132px, 1fr)); + gap: 12px; + margin-bottom: 0; +} + +.review-metrics .metric-card { + border-radius: 8px; + padding: 16px; +} + +.review-metrics .metric-card strong { + font-size: 20px; +} + +.review-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.panel-title { + align-items: center; + margin-bottom: 14px; +} + +.price-panel-title p { + margin: 5px 0 0; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.price-decision-grid { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 12px; +} + +.price-decision-card { + display: grid; + gap: 7px; + min-width: 0; + min-height: 128px; + border: 1px solid #e8ecf1; + border-radius: 8px; + background: #f8f9fe; + padding: 14px; +} + +.price-decision-card strong { + color: #1b2559; + font-size: 24px; + font-weight: 900; + line-height: 1.1; +} + +.price-decision-card p, +.price-decision-card small { + margin: 0; + color: #4b5563; + font-size: 12px; + font-weight: 700; + line-height: 1.45; +} + +.price-decision-card small { + color: #8f9bba; +} + +.price-decision-card.buyer { + border-color: #bfdbfe; + background: #eff6ff; +} + +.price-decision-card.buyer strong { + color: #2563eb; +} + +.price-decision-card.rule { + border-color: #fed7aa; + background: #fff7ed; +} + +.price-decision-card.rule strong { + color: #ea580c; +} + +.price-decision-card.deposit { + border-color: #e0e7ff; + background: #f5f7ff; +} + +.detail-list { + display: grid; + gap: 10px; + margin: 0; +} + +.detail-list div { + display: grid; + grid-template-columns: 86px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.detail-list dt { + color: #8f9bba; + font-size: 12px; + font-weight: 700; +} + +.detail-list dd { + min-width: 0; + margin: 0; + color: #1f2937; + font-size: 13px; + font-weight: 700; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.inventory-grid { + display: grid; + grid-template-columns: repeat(3, minmax(110px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.inventory-grid div { + border-radius: 8px; + background: #f8f9fe; + padding: 12px; +} + +.inventory-grid strong { + display: block; + margin-top: 4px; + color: #1b2559; + font-size: 22px; + font-weight: 900; +} + +.resource-table { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.resource-table div { + display: grid; + grid-template-columns: minmax(120px, 1fr) 72px minmax(120px, 1fr); + gap: 10px; + align-items: center; + border-bottom: 1px solid #f0f2f5; + padding-bottom: 8px; +} + +.resource-table strong { + color: #1f2937; + font-size: 13px; + font-weight: 800; +} + +.resource-table em { + color: #8f9bba; + font-size: 12px; + font-style: normal; + font-weight: 600; +} + +.skin-list { + align-items: center; +} + +.skin-list > span { + color: #8f9bba; + font-size: 13px; +} + +.screenshot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} + +.screenshot-tile { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + border: 1px solid #e8ecf1; + border-radius: 8px; + background: #f8f9fe; + padding: 0; + cursor: pointer; +} + +.screenshot-tile img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.screenshot-tile span { + position: absolute; + top: 8px; + left: 8px; + border-radius: 6px; + background: rgba(245, 158, 11, 0.95); + padding: 3px 7px; + color: #ffffff; + font-size: 12px; + font-weight: 800; +} + +.empty-warning { + display: flex; + align-items: center; + gap: 8px; + min-height: 84px; + border-radius: 8px; + background: #fff7ed; + padding: 16px; + color: #c2410c; + font-weight: 700; +} + +.dialog-screenshots { + max-height: 540px; + overflow: auto; +} + +.reject-dialog p { + margin-top: 0; +} + +.reject-reasons { + margin: 12px 0; +} + +.price-adjust-dialog { + display: grid; + gap: 14px; +} + +.adjust-current { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.adjust-current div { + display: grid; + gap: 5px; + border-radius: 8px; + background: #f8f9fe; + padding: 12px; +} + +.adjust-current strong { + color: #1b2559; + font-size: 18px; + font-weight: 900; +} + +.adjust-current .preview-value { + color: #2563eb; +} + +.adjust-mode { + width: 100%; +} + +.adjust-mode :deep(.el-radio-button) { + width: 50%; +} + +.adjust-mode :deep(.el-radio-button__inner) { + width: 100%; + border-color: #d7deea; + font-weight: 800; +} + +.adjust-mode :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { + border-color: #2563eb; + background: #2563eb; + box-shadow: -1px 0 0 0 #2563eb; +} + +.adjust-field { + display: grid; + gap: 8px; +} + +.adjust-field > span { + color: #52607a; + font-size: 12px; + font-weight: 800; +} + +.adjust-field :deep(.el-input-number) { + width: 100%; +} + +.empty-detail { + min-height: 420px; + display: grid; + place-items: center; + border-radius: 8px; + background: #ffffff; +} + +@media (max-width: 1280px) { + .review-workbench { + grid-template-columns: 1fr; + } + + .review-queue { + position: static; + } + + .queue-list { + max-height: none; + } +} + +@media (max-width: 900px) { + .review-summary, + .price-decision-grid, + .review-grid { + grid-template-columns: 1fr; + } + + .adjust-current { + grid-template-columns: 1fr; + } + + .hero-title { + flex-direction: column; + } + + .hero-actions { + justify-content: flex-start; + } + + .queue-toolbar { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/admin/views/AdminListingReviewView.vue b/frontend/src/features/admin/views/AdminListingReviewView.vue index 6eed773..0559b70 100644 --- a/frontend/src/features/admin/views/AdminListingReviewView.vue +++ b/frontend/src/features/admin/views/AdminListingReviewView.vue @@ -1053,595 +1053,4 @@ async function openScreenshot(url: string) { - + diff --git a/frontend/src/features/admin/views/AdminOperatingExpensesView.css b/frontend/src/features/admin/views/AdminOperatingExpensesView.css new file mode 100644 index 0000000..1815fb5 --- /dev/null +++ b/frontend/src/features/admin/views/AdminOperatingExpensesView.css @@ -0,0 +1,398 @@ +.finance-page-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 0 4px 32px; +} + +/* ================= 头部卡片 ================= */ +.page-header-card { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #ea580c; + background: #fff7ed; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 核心指标卡片 ================= */ +.summary-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.metric-card { + background: #ffffff; + border-radius: 16px; + padding: 18px 20px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + align-items: center; + gap: 16px; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.metric-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.metric-card--paid::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.metric-card--total::before { + background: linear-gradient(90deg, #64748b, #94a3b8); +} + +.metric-icon-wrap { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.metric-icon-wrap.amber { + background: #fffbeb; + color: #f59e0b; +} + +.metric-icon-wrap.slate { + background: #f1f5f9; + color: #64748b; +} + +.metric-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.metric-val { + font-size: 22px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.3px; + line-height: 1.2; +} + +.amber-val { + color: #d97706; +} + +.metric-sub-pill { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + width: fit-content; +} + +.metric-sub-pill.warning { + background: #fffbeb; + color: #d97706; +} + +.metric-sub-text { + font-size: 11.5px; + color: #94a3b8; +} + +/* ================= 筛选面板 ================= */ +.section-card { + background: #ffffff; + border-radius: 16px; + padding: 22px 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px 16px; +} + +.filter-grid :deep(.el-form-item) { + margin-bottom: 0; +} + +.filter-grid :deep(.el-form-item__label) { + padding-bottom: 4px; + font-size: 12px; + font-weight: 600; + color: #64748b; +} + +.full-control { + width: 100%; +} + +/* ================= 表格卡片 ================= */ +.table-section-card { + padding-bottom: 16px; +} + +.table-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.table-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.header-icon { + color: #ea580c; + font-size: 18px; +} + +.count-badge { + font-size: 12px; + font-weight: 600; + color: #ea580c; + background: #fff7ed; + padding: 3px 10px; + border-radius: 20px; +} + +.table-container { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__body-wrapper td) { + padding: 12px 0; + font-size: 13px; + color: #334155; +} + +.mono-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12.5px; + font-weight: 600; + color: #1e293b; +} + +.category-tag { + font-weight: 600; +} + +.payee-name { + color: #0f172a; +} + +.amount-val { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 14px; + font-weight: 700; +} + +.orange-val { + color: #ea580c; +} + +.time-cell { + font-size: 12px; + color: #475569; +} + +.operator-name { + font-size: 12.5px; + color: #475569; +} + +.remark-text { + font-size: 12px; + color: #64748b; +} + +.action-cell { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +/* ================= 弹窗样式 ================= */ +.voucher-upload-box { + display: flex; + align-items: center; + gap: 16px; +} + +.voucher-preview-wrap { + width: 60px; + height: 60px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.voucher-img-preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-content-wrap { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13.5px; + border-bottom: 1px dashed #f1f5f9; + padding-bottom: 8px; +} + +.detail-row.column-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.detail-label { + color: #64748b; +} + +.detail-val { + color: #0f172a; +} + +.detail-img-container { + max-width: 100%; + max-height: 240px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.detail-voucher-img { + max-width: 100%; + max-height: 240px; + object-fit: contain; +} + +.void-info-box { + background: #fef2f2; + border-radius: 8px; + padding: 12px 14px; + border: 1px solid #fee2e2; + color: #b91c1c; + font-size: 12.5px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.void-title { + display: flex; + align-items: center; + gap: 6px; + font-weight: 700; + margin-bottom: 2px; +} diff --git a/frontend/src/features/admin/views/AdminOperatingExpensesView.vue b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue index 323040d..a12682d 100644 --- a/frontend/src/features/admin/views/AdminOperatingExpensesView.vue +++ b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue @@ -560,403 +560,4 @@ function statusType(status: string): 'success' | 'danger' { - + diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.css b/frontend/src/features/admin/views/AdminOrderDetailView.css new file mode 100644 index 0000000..00c90e9 --- /dev/null +++ b/frontend/src/features/admin/views/AdminOrderDetailView.css @@ -0,0 +1,318 @@ +.order-metrics { + margin-bottom: 20px; +} + +.order-metrics .metric-card strong { + font-size: 20px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.metric-card small { + display: block; + margin-top: 8px; + color: #8f9bba; + font-size: 12px; + line-height: 1.4; + word-break: break-word; +} + +.platform-handoff-button { + min-width: 96px; + border-color: #2563eb; + background: #2563eb; + color: #ffffff; + font-weight: 700; + box-shadow: 0 6px 14px rgba(37, 99, 235, 0.18); +} + +.platform-handoff-button:hover, +.platform-handoff-button:focus { + border-color: #1d4ed8; + background: #1d4ed8; + color: #ffffff; +} + +.platform-handoff-button:active { + border-color: #1e40af; + background: #1e40af; +} + +.order-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr); + gap: 20px; + margin-bottom: 28px; +} + +.detail-panel { + min-width: 0; +} + +.order-wide-panel { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 0; +} + +.panel-subtitle { + color: #8f9bba; + font-size: 13px; + line-height: 1.6; + text-align: right; +} + +.status-tags, +.snapshot-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.detail-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + margin: 0; +} + +.detail-list div { + min-width: 0; + border-top: 1px solid #f0f2f5; + padding-top: 10px; +} + +.detail-list .wide { + grid-column: 1 / -1; +} + +.detail-list dt { + margin-bottom: 5px; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.detail-list dd { + margin: 0; + color: #1b2559; + font-size: 14px; + font-weight: 700; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.money-list { + display: grid; + gap: 8px; +} + +.money-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid #f0f2f5; + padding-top: 9px; + color: #52616f; + font-size: 13px; +} + +.money-row strong { + color: #1b2559; + font-size: 15px; +} + +.money-row.total { + margin-top: 4px; + border-top-color: #d8e2f0; + color: #1b2559; + font-weight: 700; +} + +.money-row.total strong { + font-size: 18px; +} + +.record-list { + display: grid; + gap: 12px; +} + +.record-item { + display: grid; + gap: 8px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 14px; +} + +.record-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.record-main strong { + color: #1b2559; + font-size: 15px; +} + +.record-main span { + color: #8f9bba; + font-size: 13px; +} + +.record-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 6px 12px; + color: #52616f; + font-size: 12px; + line-height: 1.5; +} + +.record-item p { + margin: 0; + color: #52616f; + line-height: 1.6; + white-space: pre-wrap; +} + +.snapshot-detail-list { + margin-bottom: 14px; +} + +.ratio-row { + display: flex; + gap: 24px; + margin-bottom: 14px; + padding: 12px 14px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; +} + +.ratio-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ratio-label { + font-size: 12px; + color: #8f9bba; + font-weight: 600; +} + +.ratio-value { + font-size: 20px; + font-weight: 700; + color: #1b2559; +} + +.snapshot-tags { + justify-content: flex-start; + margin-bottom: 14px; +} + +.snapshot-resources { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.resource-item { + display: grid; + gap: 4px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 10px 12px; +} + +.resource-item strong { + color: #1b2559; + font-size: 13px; +} + +.resource-item span { + color: #52616f; + font-size: 12px; +} + +.snapshot-json { + overflow-x: auto; + max-height: 360px; + margin: 0; + border-radius: 8px; + background: #111827; + color: #dbe4ee; + padding: 14px; + line-height: 1.6; +} + +.dialog-body { + display: grid; + gap: 12px; +} + +.dialog-body p { + margin: 0; + color: #52616f; + line-height: 1.6; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.form-grid label { + display: grid; + gap: 6px; + color: #52616f; + font-size: 13px; + font-weight: 600; +} + +.form-grid :deep(.el-input-number) { + width: 100%; +} + +@media (max-width: 960px) { + .order-detail-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .panel-heading, + .record-main { + align-items: flex-start; + flex-direction: column; + } + + .detail-list { + grid-template-columns: 1fr; + } + + .status-tags { + justify-content: flex-start; + } +} diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index 01d4aec..7fcef82 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -46,7 +46,10 @@ import { } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay' +import AdminOrderMetrics, { type AdminOrderMetric } from '../components/AdminOrderMetrics.vue' +import AdminOrderPaymentRecords from '../components/AdminOrderPaymentRecords.vue' import ProcessTimeline from '../components/ProcessTimeline.vue' +import { estimateOrderEndAt, paymentStatusLabel } from '../utils/adminOrderPresentation' const route = useRoute() const router = useRouter() @@ -255,6 +258,63 @@ const latestPayment = computed(() => { const paidOrderPayment = computed(() => paymentRecords.value.find(item => item.biz_type === 'order_pay' && item.status === 'paid') ) +const orderMetricRows = computed(() => { + const current = order.value + if (!current) return [] + const rows: AdminOrderMetric[] = [ + { + key: 'order-status', + label: '订单状态', + value: orderStatusLabel(current.status), + detail: orderHandoffStatusLabel(current), + }, + { + key: 'settlement-status', + label: '结算状态', + value: settlementStatusLabel(current.settlement_status), + detail: `更新 ${formatDateTime(current.updated_at)}`, + }, + ] + if (isPlatformManaged.value) { + rows.push({ + key: 'offline-settlement', + label: '线下结算', + value: offlineSettlementStatusLabel(offlineSettlementStatus.value), + detail: moneyCent(current.offline_settlement_amount_cent), + }) + } + rows.push( + { + key: 'total', + label: '订单总额', + value: moneyCent(orderTotalCent.value), + detail: `租金 ${moneyCent(current.rent_amount_cent)} / 押金 ${moneyCent(current.deposit_amount_cent)}`, + }, + { + key: 'payment', + label: '支付状态', + value: latestPayment.value ? paymentStatusLabel(latestPayment.value.status) : '暂无流水', + detail: paidOrderPayment.value + ? `支付 ${moneyCent(paidOrderPayment.value.amount_cent)}` + : '尚未支付', + }, + { + key: 'refund', + label: '退款状态', + value: refundStatusLabel(refundStatus.value?.refund_status || ''), + detail: refundStatus.value?.refund_amount_cent + ? moneyCent(refundStatus.value.refund_amount_cent) + : '暂无退款金额', + }, + { + key: 'estimated-end', + label: '预计截止', + value: formatDateTime(orderEstimatedEndAt(), '未设置'), + detail: `${current.estimated_duration_hours} 小时`, + } + ) + return rows +}) const currentSnapshot = computed(() => readSnapshot(order.value)) const snapshotHafCoinM = computed(() => getSnapshotHafCoinM(order.value)) const checkoutRemainingHafCoinM = computed(() => { @@ -695,12 +755,11 @@ function orderRentedAt() { } function orderEstimatedEndAt() { - if (!order.value) return undefined - if (order.value.estimated_end_at) return order.value.estimated_end_at - const rentedAt = orderRentedAt() - const durationHours = Number(order.value.estimated_duration_hours || 0) - if (!rentedAt || durationHours <= 0) return undefined - return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString() + return estimateOrderEndAt( + order.value?.estimated_end_at, + orderRentedAt(), + Number(order.value?.estimated_duration_hours || 0) + ) } async function handleRefund() { @@ -739,39 +798,6 @@ async function confirmRefund() { await handleRefund() } -function paymentStatusLabel(status: string) { - const map: Record = { - created: '已创建', - paying: '支付中', - paid: '已支付', - refunding: '退款中', - refunded: '已退款', - failed: '失败', - } - return map[status] || status -} - -function paymentStatusType(status: string) { - if (['paid', 'refunded'].includes(status)) return 'success' - if (status === 'failed') return 'danger' - if (['paying', 'refunding'].includes(status)) return 'warning' - return 'info' -} - -function paymentBizTypeLabel(type: string) { - const map: Record = { - order_pay: '订单支付', - checkout_refund: '结账退款', - arbitration_refund: '仲裁退款', - admin_refund: '人工退款', - cancel_refund: '取消退款', - admin_close_refund: '客服关闭退款', - admin_seal_refund: '封存退款', - deposit_refund: '暂扣押金归还', - } - return map[type] || type -} - function moneyCent(value: number | undefined | null) { return formatCentWithSymbol(Number(value || 0)) } @@ -943,10 +969,6 @@ function formatRatioNumber(value: number) { const rounded = Math.round(value * 100) / 100 return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2) } - -function paymentPaidAt(record: AdminPayment) { - return record.paid_at || record.updated_at || record.created_at -} - + diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.css b/frontend/src/features/admin/views/AdminPickupDetailView.css new file mode 100644 index 0000000..2dbd2d1 --- /dev/null +++ b/frontend/src/features/admin/views/AdminPickupDetailView.css @@ -0,0 +1,209 @@ +.pickup-metrics { + margin-bottom: 20px; +} + +.pickup-metrics .metric-card strong { + font-size: 20px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.metric-card small { + display: block; + margin-top: 8px; + color: #8f9bba; + font-size: 12px; + line-height: 1.4; + word-break: break-word; +} + +.form-hint { + margin-top: 4px; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.adjustment-table { + width: 100%; +} + +.pickup-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr); + gap: 20px; + margin-bottom: 28px; +} + +.detail-panel { + min-width: 0; +} + +.pickup-wide-panel { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 0; +} + +.panel-subtitle { + color: #8f9bba; + font-size: 13px; + line-height: 1.6; + text-align: right; +} + +.detail-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + margin: 0; +} + +.detail-list div { + min-width: 0; + border-top: 1px solid #f0f2f5; + padding-top: 10px; +} + +.detail-list .wide { + grid-column: 1 / -1; +} + +.detail-list dt { + margin-bottom: 5px; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.detail-list dd { + margin: 0; + color: #1b2559; + font-size: 14px; + font-weight: 700; + line-height: 1.5; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.source-detail { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; +} + +.money-list { + display: grid; + gap: 8px; +} + +.money-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid #f0f2f5; + padding-top: 9px; + color: #52616f; + font-size: 13px; +} + +.money-row strong { + color: #1b2559; + font-size: 15px; +} + +.money-row.is-subtotal { + border-top-color: #d8e2f0; + color: #1b2559; + font-weight: 700; +} + +.money-row.is-profit strong { + color: #0f766e; +} + +.money-row.is-total { + margin-top: 4px; + border-top-color: #bfdbfe; + color: #1b2559; + font-weight: 800; +} + +.money-row.is-total strong { + font-size: 18px; +} + +.snapshot-detail-list { + margin-bottom: 14px; +} + +.snapshot-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.snapshot-resources { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.resource-item { + display: grid; + gap: 4px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 10px 12px; +} + +.resource-item strong { + color: #1b2559; + font-size: 13px; +} + +.resource-item span { + color: #52616f; + font-size: 12px; +} + +.snapshot-json { + overflow-x: auto; + max-height: 360px; + margin: 0; + border-radius: 8px; + background: #0f172a; + color: #dbeafe; + padding: 14px; + font-size: 12px; + line-height: 1.6; +} + +@media (max-width: 960px) { + .pickup-detail-grid { + grid-template-columns: 1fr; + } + + .pickup-wide-panel { + grid-column: auto; + } + + .detail-list { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.vue b/frontend/src/features/admin/views/AdminPickupDetailView.vue index 0d0a373..159b46d 100644 --- a/frontend/src/features/admin/views/AdminPickupDetailView.vue +++ b/frontend/src/features/admin/views/AdminPickupDetailView.vue @@ -697,214 +697,4 @@ function readSnapshotResources(summary: Record | null): Snapsho - + diff --git a/frontend/src/features/admin/views/AdminPickupView.css b/frontend/src/features/admin/views/AdminPickupView.css new file mode 100644 index 0000000..639fda6 --- /dev/null +++ b/frontend/src/features/admin/views/AdminPickupView.css @@ -0,0 +1,120 @@ +.form-hint { + margin-top: 4px; + font-size: 12px; + color: #64748b; +} +.pickup-filter-bar { + margin-bottom: 16px; +} + +.pickup-filter-bar :deep(.el-form) { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; +} + +.pickup-filter-bar :deep(.el-form-item) { + margin: 0; +} + +.pickup-filter-bar :deep(.el-form-item__label) { + padding-right: 7px; + color: #52616f; + font-size: 13px; +} + +.pickup-filter-bar :deep(.el-select) { + width: 128px; +} + +.pickup-filter-bar :deep(.el-form-item:nth-child(3) .el-select) { + width: 150px; +} + +.pickup-filter-bar :deep(.el-form-item:nth-child(4) .el-select) { + width: 175px; +} + +.pickup-keyword-filter :deep(.el-input) { + width: 280px; +} + +.pickup-filter-actions :deep(.el-form-item__content) { + gap: 8px; +} + +@media (max-width: 900px) { + .pickup-filter-bar :deep(.el-form) { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .pickup-filter-bar :deep(.el-form-item), + .pickup-filter-bar :deep(.el-select), + .pickup-keyword-filter :deep(.el-input) { + width: 100%; + } + + .pickup-filter-bar :deep(.el-form-item__content) { + min-width: 0; + flex: 1; + } +} + +@media (max-width: 600px) { + .pickup-filter-bar :deep(.el-form) { + grid-template-columns: minmax(0, 1fr); + } +} +.text-muted { + color: #94a3b8; +} +.price-snapshot { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + color: #475569; + font-size: 13px; + line-height: 1.6; +} +.source-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; +} +.source-cell small { + color: #64748b; +} +.listing-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} +.listing-option-title { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.listing-option-price { + flex: none; + color: #64748b; + font-variant-numeric: tabular-nums; +} +.selected-source { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + color: #475569; + font-size: 13px; +} +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/frontend/src/features/admin/views/AdminPickupView.vue b/frontend/src/features/admin/views/AdminPickupView.vue index ef6d8fd..2ee53db 100644 --- a/frontend/src/features/admin/views/AdminPickupView.vue +++ b/frontend/src/features/admin/views/AdminPickupView.vue @@ -902,125 +902,4 @@ function listingOptionLabel(item: AvailableListing) { - + diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.css b/frontend/src/features/admin/views/AdminQrCodePoolView.css new file mode 100644 index 0000000..4e40066 --- /dev/null +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.css @@ -0,0 +1,448 @@ +.qrcode-pool-view { + display: flex; + flex-direction: column; + gap: 16px; +} + +.stats-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.stat-card { + text-align: center; +} + +.toolbar-card, +.table-card { + border-radius: 10px; +} + +.table-card :deep(.el-card__body) { + padding: 20px; +} + +.stat-value { + font-size: 28px; + font-weight: 600; + line-height: 1.2; +} + +.stat-value.success { + color: var(--el-color-success); +} +.stat-value.primary { + color: var(--el-color-primary); +} +.stat-value.danger { + color: var(--el-color-danger); +} + +.stat-label { + margin-top: 6px; + color: var(--el-text-color-secondary); + font-size: 13px; +} + +.toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.filter-area, +.action-area { + display: flex; + gap: 8px; + align-items: center; +} + +.filter-area { + flex: 1 1 auto; + flex-wrap: wrap; +} + +.action-area { + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; +} + +.keyword-filter { + width: 240px; +} + +.compact-filter { + width: 128px; +} + +.date-range-filter { + width: 330px; +} + +.qrcode-table { + border-radius: 10px; + box-shadow: none; + padding: 0; +} + +.qrcode-table :deep(.el-table__inner-wrapper) { + min-width: 0; +} + +.qrcode-table :deep(.el-table__header th) { + height: 42px; + background: #f8f9fb; +} + +.qrcode-table :deep(.el-table__cell) { + padding: 10px 0; +} + +.qrcode-table :deep(.cell) { + padding: 0 14px; +} + +.qrcode-table :deep(.el-table__fixed-right) { + box-shadow: -8px 0 12px rgba(31, 45, 61, 0.04); +} + +.qrcode-table :deep(.el-table__header), +.qrcode-table :deep(.el-table__body) { + width: 100% !important; +} + +.qrcode-table :deep(.el-table__header-wrapper), +.qrcode-table :deep(.el-table__body-wrapper) { + overflow-x: auto; +} + +.qrcode-table :deep(.selection-col) { + width: 4%; +} + +.qrcode-table :deep(.qr-col) { + width: 8%; +} + +.qrcode-table :deep(.status-col) { + width: 8%; +} + +.qrcode-table :deep(.name-col) { + width: 17%; +} + +.qrcode-table :deep(.note-col) { + width: 17%; +} + +.qrcode-table :deep(.group-col) { + width: 17%; +} + +.qrcode-table :deep(.rename-col) { + width: 8%; +} + +.qrcode-table :deep(.time-col) { + width: 12%; +} + +.qrcode-table :deep(.action-col) { + width: 10%; +} + +.qrcode-thumb { + display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border: 1px solid #eef1f5; + border-radius: 8px; + background: #ffffff; +} + +.note-cell { + color: #303846; + font-weight: 500; +} + +.time-stack { + display: inline-flex; + flex-direction: column; + gap: 2px; + color: #5f6b7a; + font-size: 12px; + line-height: 18px; + text-align: left; + white-space: nowrap; +} + +.copy-cell-button { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: #303846; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 20px; + text-align: left; +} + +.copy-cell-button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.copy-cell-button .el-icon { + flex: 0 0 auto; + color: var(--el-color-primary); + font-size: 14px; +} + +.copy-cell-button:disabled { + color: #a8b0bd; + cursor: default; +} + +.table-actions { + display: flex; + justify-content: center; + gap: 6px; + white-space: nowrap; +} + +.table-actions :deep(.el-button) { + margin-left: 0; + padding: 4px 6px; +} + +@media (max-width: 1280px) { + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .action-area { + justify-content: flex-start; + } +} + +@media (max-width: 720px) { + .keyword-filter, + .compact-filter, + .date-range-filter { + width: 100%; + } + + .filter-area, + .action-area { + align-items: stretch; + } +} + +.upload-preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.batch-upload-area { + width: 100%; +} + +.qrcode-upload-form :deep(.el-form-item__label) { + align-items: center; + padding-right: 18px; + line-height: 32px; + white-space: nowrap; + word-break: keep-all; +} + +.qrcode-upload-form :deep(.el-form-item) { + margin-bottom: 18px; +} + +.qrcode-upload-form :deep(.el-form-item__content) { + min-width: 0; +} + +.batch-upload-area :deep(.el-upload) { + width: 100%; +} + +.batch-upload-area :deep(.el-upload-dragger) { + width: 100%; + padding: 12px 18px; +} + +.batch-upload-area :deep(.el-icon--upload) { + margin-bottom: 4px; + font-size: 28px; + line-height: 1; +} + +.batch-upload-area :deep(.el-upload__text) { + line-height: 20px; +} + +.upload-tip { + color: var(--el-text-color-secondary); + font-size: 12px; + line-height: 18px; +} + +.uploaded-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; + margin-top: 10px; + max-height: 320px; + overflow-y: auto; + padding: 10px; + border: 1px solid #eef1f5; + border-radius: 8px; + background: #fafbfc; +} + +.uploaded-item { + min-width: 0; + padding: 10px; + border: 1px solid #e7ebf2; + border-radius: 8px; + background: #ffffff; +} + +.uploaded-main { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.uploaded-thumb { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + flex: 0 0 auto; + border: 1px solid #e7ebf2; + border-radius: 8px; + background: #ffffff; +} + +.uploaded-local-preview { + display: block; + width: 52px; + height: 52px; + border-radius: 6px; + object-fit: cover; +} + +.uploaded-remove { + position: absolute; + top: -7px; + right: -7px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid #ffffff; + border-radius: 50%; + background: var(--el-color-danger); + color: #ffffff; + cursor: pointer; + font-size: 14px; + line-height: 1; +} + +.uploaded-remove:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.uploaded-name { + overflow: hidden; + color: var(--el-text-color-secondary); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uploaded-meta { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.uploaded-meta .el-tag { + height: 20px; +} + +.uploaded-group-editor { + margin-top: 8px; +} + +.expired-tag { + color: var(--el-color-danger); +} + +.edit-image-area { + display: flex; + align-items: center; + gap: 12px; +} + +.edit-image-preview { + flex: 0 0 auto; + width: 88px; + height: 88px; + overflow: hidden; + border: 1px solid #e7ebf2; + border-radius: 8px; + background: #fafbfc; +} + +.edit-image-preview :deep(.el-image), +.edit-image-preview :deep(img) { + display: block; + width: 100%; + height: 100%; +} + +.edit-image-empty { + display: grid; + width: 100%; + height: 100%; + place-items: center; + color: var(--el-text-color-secondary); + font-size: 12px; +} + +.expire-editor { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue index d53a026..ef78aac 100644 --- a/frontend/src/features/admin/views/AdminQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -979,453 +979,4 @@ onMounted(reloadAll) - + diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.css b/frontend/src/features/admin/views/AdminSystemConfigsView.css new file mode 100644 index 0000000..99f3d4c --- /dev/null +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.css @@ -0,0 +1,89 @@ +.publish-config-panel { + display: grid; + gap: 18px; + margin-bottom: 18px; + padding: 20px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.publish-config-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.panel-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.publish-config-main h2 { + margin: 4px 0 6px; + color: #111827; + font-size: 20px; +} + +.publish-config-main span { + color: #6b7280; + font-size: 13px; +} + +.publish-stat-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} + +.welcome-config-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.home-stat-grid .publish-stat strong { + font-size: 18px; +} + +.publish-stat { + display: grid; + gap: 4px; + padding: 12px; + border-radius: 8px; + background: #f8fafc; +} + +.publish-stat strong { + color: #1477ff; + font-size: 22px; + line-height: 1; +} + +.publish-stat span, +.config-value { + color: #4b5563; + font-size: 13px; + overflow-wrap: anywhere; +} + +/* 仅实体主按钮提升文字清晰度,其他按钮保持组件默认配色。 */ +:deep(.el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link)) { + font-weight: 700; +} + +:deep(.el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link) span) { + color: #ffffff !important; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + font-weight: 700; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@media (max-width: 1100px) { + .publish-stat-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index 31a1021..efcaf4b 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -965,94 +965,4 @@ function formatConfigValue(row: SystemConfig) { - + diff --git a/frontend/src/features/admin/views/AdminUsersView.css b/frontend/src/features/admin/views/AdminUsersView.css new file mode 100644 index 0000000..c51733e --- /dev/null +++ b/frontend/src/features/admin/views/AdminUsersView.css @@ -0,0 +1,149 @@ +.user-filter-bar { + margin-bottom: 16px; + padding: 16px 16px 0; + border-radius: 8px; + background: #fff; +} + +.user-filter-bar :deep(.el-form-item) { + margin-bottom: 16px; +} + +.balance-available { + color: #0f172a; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.balance-frozen { + color: #94a3b8; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.balance-frozen.is-positive { + color: #c2410c; +} + +.quota-cell { + display: grid; + gap: 2px; +} + +.quota-cell strong { + color: #0f172a; + font-variant-numeric: tabular-nums; +} + +.quota-cell span { + color: #64748b; + font-size: 11px; + line-height: 1.3; +} + +.table-actions { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 2px; + white-space: nowrap; +} + +.table-actions :deep(.el-button) { + margin: 0; + height: 28px; + padding: 0 8px; + font-weight: 600; +} + +.table-actions :deep(.el-button + .el-button) { + margin-left: 0; +} + +.table-actions :deep(.el-button.is-text) { + border-radius: 6px; +} + +.table-actions :deep(.el-button.is-text:hover) { + background: #f1f5f9; +} + +.wallet-dialog { + display: grid; + gap: 14px; +} + +.quota-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; + color: #64748b; + font-size: 13px; +} + +.quota-summary strong { + color: #0f172a; +} + +.growth-dialog { + gap: 16px; +} + +.growth-current { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; + color: #64748b; +} + +.growth-current strong { + color: #0f172a; + font-size: 22px; + font-variant-numeric: tabular-nums; +} + +.wallet-balance-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.wallet-balance-cards > div { + display: grid; + gap: 6px; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.wallet-balance-cards span { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.wallet-balance-cards strong { + color: #0f172a; + font-size: 22px; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.full-control { + width: 100%; +} + +.dialog-body { + display: grid; + gap: 12px; +} diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index 5674c42..c47740d 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -755,154 +755,4 @@ function moneyCent(value: number | string | undefined) { - + diff --git a/frontend/src/features/auth/views/MobileProfileView.css b/frontend/src/features/auth/views/MobileProfileView.css new file mode 100644 index 0000000..0f2c930 --- /dev/null +++ b/frontend/src/features/auth/views/MobileProfileView.css @@ -0,0 +1,665 @@ +.mobile-profile-shell { + min-height: 100dvh; + background: #f6f8fa; + color: #1a202c; + padding: 0 0 calc(64px + env(safe-area-inset-bottom)); +} + +/* ========== Hero Header ========== */ +.profile-hero { + background: #ffffff; + padding: 20px 16px 20px; + border-bottom: 1px solid rgba(243, 244, 246, 0.8); +} + +.hero-top-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.app-title { + font-size: 16px; + font-weight: 700; + color: #111827; + letter-spacing: 0.5px; +} + +.hero-setting-btn { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: none; + background: #f3f4f6; + border-radius: 999px; + cursor: pointer; + transition: background 0.2s; +} + +.hero-setting-btn:active { + background: #e5e7eb; +} + +.profile-hero-content { + display: flex; + align-items: center; + gap: 16px; +} + +.avatar-circle { + display: grid; + width: 60px; + height: 60px; + place-items: center; + border-radius: 999px; + overflow: hidden; + background: #eaf2ff; + color: #1477ff; + font-size: 24px; + font-weight: 900; + border: 1px solid #e5e7eb; +} + +.avatar-circle img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.hero-user { + flex: 1; + min-width: 0; +} + +.name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.user-nickname { + margin: 0; + font-size: 18px; + font-weight: 800; + color: #111827; +} + +.verified-tag, +.unverified-tag { + font-size: 10px !important; + height: 18px; + padding: 0 6px; + font-weight: 700; + border: none; +} + +.user-meta { + margin: 4px 0 0; + color: #6b7280; + font-size: 12px; +} + +.growth-card { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 12px 16px 0; +} + +.growth-card > div { + display: grid; + gap: 4px; + min-width: 0; + padding: 14px; + border: 1px solid rgba(243, 244, 246, 0.9); + border-radius: 16px; + background: #ffffff; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.02), + 0 1px 4px rgba(0, 0, 0, 0.02); +} + +.growth-card span { + color: #9ca3af; + font-size: 11px; + font-weight: 700; +} + +.growth-card strong { + color: #7c3aed; + font-size: 20px; + line-height: 1.2; + font-weight: 900; +} + +/* ========== Balance Card ========== */ +.balance-card { + margin: 12px 16px 14px; + padding: 16px; + border-radius: 16px; + background: #ffffff; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.02), + 0 1px 4px rgba(0, 0, 0, 0.02); + border: 1px solid rgba(243, 244, 246, 0.9); +} + +.balance-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.balance-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.balance-label { + color: #9ca3af; + font-size: 11px; + font-weight: 600; +} + +.balance-value { + font-size: 26px; + font-weight: 800; + font-family: Inter, system-ui, sans-serif; + color: #111827; +} + +.withdraw-btn { + background: transparent; + border: 1px solid #1477ff; + color: #1477ff; + font-weight: 700; + font-size: 12px; + height: 28px; + padding: 0 16px; + border-radius: 999px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} + +.withdraw-btn:active { + background: #1477ff; + color: #ffffff; +} + +.balance-footer { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid #f3f4f6; +} + +.detail-btn { + background: transparent; + border: none; + color: #1477ff; + font-weight: 700; + font-size: 11px; + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 0; +} + +/* ========== Menu Cards ========== */ +.menu-card { + margin: 0 16px 14px; + background: #ffffff; + border-radius: 16px; + padding: 16px; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.02), + 0 1px 4px rgba(0, 0, 0, 0.02); + border: 1px solid rgba(243, 244, 246, 0.9); +} + +.menu-card.cell-card { + padding: 0; + overflow: hidden; +} + +.menu-card .card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; +} + +.menu-card .card-header h3 { + margin: 0; + font-size: 14px; + font-weight: 800; + color: #111827; +} + +.see-all { + font-size: 11px; + color: #6b7280; + display: flex; + align-items: center; + gap: 2px; + cursor: pointer; +} + +/* Grid Menu Layout */ +.grid-menu { + display: flex; + justify-content: space-between; + align-items: center; +} + +.grid-menu.col-4 .grid-item { + flex: 0 0 25%; +} + +.grid-menu.col-5 .grid-item { + flex: 0 0 20%; +} + +.grid-menu.col-3 .grid-item { + flex: 0 0 33.33%; +} + +.grid-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.grid-item:active .icon-wrap { + transform: scale(0.92); +} + +.icon-wrap { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border-radius: 12px; + transition: transform 0.15s ease; +} + +/* Vibrant pastel colors for menu icon wraps */ +.icon-wrap.warning { + color: #f59e0b; + background: rgba(245, 158, 11, 0.08); +} +.icon-wrap.info { + color: #2563eb; + background: rgba(37, 99, 235, 0.08); +} +.icon-wrap.primary { + color: #1477ff; + background: rgba(20, 119, 255, 0.08); +} +.icon-wrap.success { + color: #10b981; + background: rgba(16, 185, 129, 0.08); +} +.icon-wrap.orange { + color: #ff5f00; + background: rgba(255, 95, 0, 0.08); +} +.icon-wrap.purple { + color: #8b5cf6; + background: rgba(139, 92, 246, 0.08); +} +.icon-wrap.teal { + color: #0d9488; + background: rgba(13, 148, 136, 0.08); +} + +.grid-item span { + font-size: 11px; + color: #374151; + font-weight: 600; +} + +/* Vant Cell Adjustments */ +:deep(.van-cell-group) { + background: transparent; +} +:deep(.van-cell) { + padding: 14px 16px; + font-size: 14px; +} +.verified-color { + color: #10b981; + font-weight: 600; +} +.unverified-color { + color: #9ca3af; +} +.unread-cell-value { + color: #ef4444; + font-weight: 700; +} + +/* 收支明细 Popup */ +.ledgers-popup { + display: flex; + flex-direction: column; +} +.popup-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #f3f4f6; + flex-shrink: 0; +} +.popup-header h3 { + margin: 0; + font-size: 16px; + font-weight: 800; +} +.popup-close { + border: none; + background: transparent; + font-size: 16px; + color: #9ca3af; + cursor: pointer; +} +.popup-body { + flex: 1; + overflow-y: auto; + padding: 0 16px; +} +.ledger-list { + display: flex; + flex-direction: column; +} +.ledger-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid #f3f4f6; +} +.ledger-item:last-child { + border-bottom: none; +} +.ledger-left { + display: flex; + flex-direction: column; + gap: 4px; +} +.ledger-remark { + font-size: 14px; + color: #1f2937; + font-weight: 600; +} +.ledger-time { + font-size: 11px; + color: #9ca3af; +} +.ledger-right { + font-size: 15px; + font-weight: 800; +} +.in-color { + color: #10b981; +} +.out-color { + color: #ef4444; +} + +/* 租后须知 Popup */ +.notice-popup { + display: flex; + flex-direction: column; +} +.notice-content { + padding: 16px 0; + color: #374151; + font-size: 14px; + line-height: 1.8; + white-space: pre-wrap; + word-wrap: break-word; +} +.center-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 0; +} + +/* ========== 设置面板 ========== */ +.settings-panel { + display: flex; + flex-direction: column; + height: 100%; + background: #f5f6f8; +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: #fff; + border-bottom: 1px solid #eee; +} + +.settings-header h2 { + margin: 0; + font-size: 17px; + font-weight: 700; +} + +.settings-close { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: none; + background: none; + color: #999; + cursor: pointer; +} + +.settings-group { + margin-top: 12px; +} + +.settings-group-title { + margin: 0 0 4px 16px; + font-size: 13px; + font-weight: 600; + color: #999; +} + +.settings-group :deep(.van-cell) { + font-size: 15px; +} + +.settings-logout { + margin: auto 16px 24px; + padding-top: 20px; +} + +/* ========== 资料修改 ========== */ +.profile-editor { + padding: 16px 16px max(18px, env(safe-area-inset-bottom)); + background: #fff; +} + +.profile-editor-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.profile-editor-header h2 { + margin: 0; + color: #17233d; + font-size: 18px; + font-weight: 800; +} + +.profile-editor-header button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: none; + border-radius: 12px; + background: #f4f6f8; + color: #64748b; +} + +.profile-preview { + display: flex; + align-items: center; + gap: 12px; + margin: 18px 0; + padding: 14px; + border-radius: 14px; + background: #f7faff; +} + +.profile-preview-avatar { + display: grid; + width: 52px; + height: 52px; + flex: 0 0 auto; + place-items: center; + overflow: hidden; + border-radius: 16px; + background: #1477ff; + color: #fff; + font-size: 22px; + font-weight: 900; +} + +.profile-preview-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-preview-avatar.clickable { + cursor: pointer; + position: relative; +} + +.avatar-upload-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + opacity: 0; + transition: opacity 0.2s ease; +} + +.profile-preview-avatar.clickable:hover .avatar-upload-overlay, +.profile-preview-avatar.clickable:active .avatar-upload-overlay { + opacity: 1; +} + +.avatar-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.8); +} + +.preview-info { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.preview-info strong { + display: block; + max-width: 230px; + overflow: hidden; + color: #17233d; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-info p { + margin: 0; + color: #7b8798; + font-size: 12px; +} + +.upload-btn { + margin-top: 4px; + font-weight: 600; +} + +.editor-field { + display: block; + margin-bottom: 12px; +} + +.password-hint { + margin: 8px 0 14px; + color: #8b9cb5; + font-size: 12px; + line-height: 1.5; +} + +.editor-field span { + display: block; + margin-bottom: 6px; + color: #697386; + font-size: 13px; + font-weight: 700; +} + +.editor-field input { + box-sizing: border-box; + width: 100%; + height: 46px; + border: 1px solid #e1e7ef; + border-radius: 12px; + background: #fff; + color: #17233d; + font-size: 14px; + outline: none; + padding: 0 12px; +} + +.editor-field input:focus { + border-color: #1477ff; + box-shadow: 0 0 0 3px rgba(20, 119, 255, 0.1); +} + +.profile-save-btn { + height: 44px; + margin-top: 6px; + background: #1477ff; + border-color: transparent; + font-weight: 800; +} + +/* ========== 响应式适配 ========== */ +@media (min-width: 520px) { + .mobile-profile-shell { + max-width: 430px; + margin: 0 auto; + box-shadow: 0 0 0 1px rgba(23, 35, 61, 0.08); + } +} diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index bc178c0..856cdb9 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -691,670 +691,4 @@ function resolveAvatarURL(url: string | undefined | null) { - + diff --git a/frontend/src/features/financial/__tests__/financialApi.spec.ts b/frontend/src/features/financial/__tests__/financialApi.spec.ts new file mode 100644 index 0000000..549e9e8 --- /dev/null +++ b/frontend/src/features/financial/__tests__/financialApi.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' + +const apiClient = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), +})) + +vi.mock('@/shared/api/client', () => ({ apiClient })) + +import { fetchWalletBalance, fetchWalletLedger } from '@/features/wallet/api/wallet' +import { + cancelWithdrawal, + createPaymentAccount, + createWithdrawal, + deletePaymentAccount, + fetchPaymentAccount, + fetchPaymentAccounts, + fetchWithdrawal, + fetchWithdrawals, + setDefaultPaymentAccount, + updatePaymentAccount, +} from '@/features/wallet/api/withdrawal' +import { fetchAdminPayments } from '@/features/admin/api/adminPayments' + +describe('financial API clients', () => { + it('fetches wallet balance and paginated ledger with server data', async () => { + apiClient.get + .mockResolvedValueOnce({ data: { data: { available_balance_cent: 100 } } }) + .mockResolvedValueOnce({ data: { data: { items: [], total: 0, page: 2, page_size: 10 } } }) + + await expect(fetchWalletBalance()).resolves.toEqual({ available_balance_cent: 100 }) + await expect(fetchWalletLedger(2, 10)).resolves.toEqual({ + items: [], + total: 0, + page: 2, + page_size: 10, + }) + expect(apiClient.get).toHaveBeenNthCalledWith(2, '/wallet/ledger', { + params: { page: 2, page_size: 10 }, + }) + }) + + it('supports payment-account CRUD and default selection', async () => { + const account = { id: 7, account_type: 'alipay' } + apiClient.get + .mockResolvedValueOnce({ data: { data: { items: [account], total: 1 } } }) + .mockResolvedValueOnce({ data: { data: account } }) + apiClient.post.mockResolvedValueOnce({ data: { data: account } }) + apiClient.put.mockResolvedValueOnce({ data: { data: account } }) + apiClient.delete.mockResolvedValueOnce({ data: { data: { deleted: true } } }) + apiClient.post.mockResolvedValueOnce({ data: { data: { updated: true } } }) + + await expect(fetchPaymentAccounts()).resolves.toMatchObject({ items: [account], total: 1 }) + await expect(fetchPaymentAccount(7)).resolves.toEqual(account) + await expect( + createPaymentAccount({ account_type: 'alipay', account_name: 'A', account_no: 'x' }) + ).resolves.toEqual(account) + await expect(updatePaymentAccount(7, { is_default: true })).resolves.toEqual(account) + await expect(deletePaymentAccount(7)).resolves.toEqual({ deleted: true }) + await expect(setDefaultPaymentAccount(7)).resolves.toEqual({ updated: true }) + }) + + it('supports withdrawal lifecycle API calls', async () => { + const withdrawal = { id: 9, status: 'pending' } + apiClient.post + .mockResolvedValueOnce({ data: { data: withdrawal } }) + .mockResolvedValueOnce({ data: { data: { cancelled: true } } }) + apiClient.get + .mockResolvedValueOnce({ data: { data: { items: [withdrawal], total: 1 } } }) + .mockResolvedValueOnce({ data: { data: withdrawal } }) + + await expect(createWithdrawal({ payment_account_id: 7, amount_cent: 1000 })).resolves.toEqual( + withdrawal + ) + await expect(fetchWithdrawals(1, 20)).resolves.toMatchObject({ items: [withdrawal] }) + await expect(fetchWithdrawal(9)).resolves.toEqual(withdrawal) + await expect(cancelWithdrawal(9)).resolves.toEqual({ cancelled: true }) + }) + + it('filters empty admin-payment query values and normalizes pagination', async () => { + apiClient.get.mockResolvedValueOnce({ data: { data: undefined } }) + + await expect( + fetchAdminPayments({ status: '', provider: 'mock', page: 3, silent: true }) + ).resolves.toEqual({ items: [], total: 0, page: 3, page_size: 20 }) + expect(apiClient.get).toHaveBeenLastCalledWith('/admin/payments', { + params: { provider: 'mock', page: 3 }, + silent: true, + }) + }) +}) diff --git a/frontend/src/features/listings/views/ListingDetailView.css b/frontend/src/features/listings/views/ListingDetailView.css new file mode 100644 index 0000000..acae137 --- /dev/null +++ b/frontend/src/features/listings/views/ListingDetailView.css @@ -0,0 +1,680 @@ +.detail-back-bar { + /* 吸顶固定:始终显示在顶部导航栏(64px)下方,滚动时不跟随页面移走 */ + position: sticky; + top: 78px; + z-index: 20; + display: flex; + align-items: center; + margin-bottom: 14px; +} + +.detail-back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 14px; + border: 1px solid #eef1f5; + border-radius: 10px; + background: #ffffff; + color: #475569; + font-size: 13px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 2px 8px rgba(23, 35, 61, 0.08); + transition: + border-color 0.2s ease, + color 0.2s ease, + background 0.2s ease, + box-shadow 0.2s ease; +} + +.detail-back-btn:hover { + border-color: #ff6a00; + background: #fff7ed; + color: #ff6a00; + box-shadow: 0 4px 12px rgba(255, 106, 0, 0.12); +} + +.pc-detail-layout { + grid-template-columns: minmax(0, 1fr) 420px; + align-items: start; +} + +.pc-detail-main { + overflow: hidden; +} + +.detail-hero-card { + position: relative; + display: grid; + height: 340px; + overflow: hidden; + background: #111827; + color: #ffffff; +} + +.detail-hero-card img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-hero-card > span { + display: grid; + height: 340px; + place-items: center; + font-size: 32px; + font-weight: 900; +} + +.detail-hero-card::after { + position: absolute; + inset: 0; + content: ''; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.06), rgba(15, 23, 42, 0.58)), + linear-gradient(90deg, rgba(15, 23, 42, 0.76), rgba(15, 23, 42, 0.08) 62%); +} + +.detail-hero-overlay { + position: absolute; + inset: auto 0 0; + z-index: 1; + max-width: 860px; + padding: 28px; +} + +.detail-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.detail-tags span { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.34); + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + color: #ffffff; + font-size: 13px; + font-weight: 900; +} + +.detail-code-chip { + width: fit-content; + margin-top: 10px; + padding: 5px 10px; + border: 1px solid rgba(147, 197, 253, 0.6); + border-radius: 999px; + background: rgba(239, 246, 255, 0.92); + color: #2563eb; + font-size: 12px; + font-weight: 900; + cursor: pointer; +} + +.detail-favorite-chip { + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + margin-top: 8px; + padding: 7px 12px; + border: 1px solid rgba(255, 106, 0, 0.42); + border-radius: 999px; + background: rgba(255, 106, 0, 0.18); + color: #fff7ed; + font-size: 12px; + font-weight: 900; + cursor: pointer; +} + +.detail-hero-overlay h1 { + margin: 0; + color: #ffffff; + font-size: 30px; + line-height: 1.25; +} + +.detail-hero-overlay p { + margin: 8px 0 0; + color: rgba(255, 255, 255, 0.82); + font-size: 16px; + font-weight: 800; +} + +.detail-body { + display: grid; + gap: 18px; + padding: 24px; +} + +.detail-summary-row { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; +} + +.detail-metric, +.detail-section { + border: 1px solid #edf0f4; + border-radius: 16px; + background: #ffffff; +} + +.detail-metric { + padding: 18px; + background: #f8fafc; +} + +.detail-metric span { + display: block; + color: #8b9cb5; + font-size: 13px; + font-weight: 900; +} + +.detail-metric strong { + display: block; + margin-top: 8px; + color: #17233d; + font-size: 28px; + line-height: 1.1; +} + +.detail-metric.is-coin strong { + color: #1477ff; +} + +.detail-metric.is-price strong { + color: #ef4444; +} + +.detail-section { + padding: 22px; +} + +.detail-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.detail-section-head h2 { + margin: 0; + color: #17233d; + font-size: 20px; +} + +.detail-section-head span { + color: #8b9cb5; + font-size: 13px; + font-weight: 900; +} + +.detail-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.detail-chip-row span { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 10px; + border: 1px solid rgba(255, 106, 0, 0.28); + border-radius: 8px; + background: #fff7ed; + color: #ea580c; + font-size: 13px; + font-weight: 900; +} + +.detail-info-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0 0; +} + +.detail-info-grid div { + min-width: 0; + border-radius: 12px; + background: #f8fafc; + padding: 14px; +} + +.detail-info-grid dt { + color: #8b9cb5; + font-size: 12px; + font-weight: 900; +} + +.detail-info-grid dd { + margin: 8px 0 0; + overflow-wrap: anywhere; + color: #17233d; + font-size: 15px; + font-weight: 900; +} + +.detail-resource-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.detail-resource-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + border-radius: 12px; + background: #f8fafc; + padding: 14px; +} + +.detail-resource-card span { + min-width: 0; + color: #475569; + font-size: 14px; + font-weight: 900; +} + +.detail-resource-card strong { + color: #17233d; + font-size: 18px; +} + +.detail-resource-card em { + display: flex; + align-items: center; + justify-content: space-between; + grid-column: 1 / -1; + font-size: 12px; + font-style: normal; + font-weight: 900; +} + +.detail-resource-card em b { + color: #ff6a00; +} + +.detail-resource-card em small { + color: #17233d; + font-size: 13px; + font-weight: 900; +} + +.skin-groups { + display: grid; + gap: 16px; +} + +.skin-group h3 { + margin: 0 0 10px; + color: #475569; + font-size: 15px; +} + +.detail-description { + margin: 0; + color: #52616f; + font-size: 15px; + line-height: 1.8; +} + +.detail-screenshot-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.detail-screenshot { + margin: 0; +} + +.detail-screenshot-image { + width: 100%; + aspect-ratio: 16 / 10; + overflow: hidden; + border-radius: 12px; + border: 1px solid #edf0f4; + background: #f8fafc; + cursor: zoom-in; +} + +.detail-screenshot-image :deep(.el-image__inner) { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-screenshot figcaption { + margin-top: 8px; + color: #64748b; + font-size: 13px; + font-weight: 800; + text-align: center; +} + +.pc-order-card { + max-width: none; + border-radius: 20px; + padding: 24px; + overflow: hidden; +} + +.order-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 10px; +} + +.order-card-head h2 { + margin: 4px 0 0; + color: #17233d; + font-size: 22px; + line-height: 1.2; +} + +.order-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + border-radius: 999px; + background: #f1f5f9; + padding: 0 10px; + color: #64748b; + font-size: 12px; + font-weight: 900; +} + +.order-state { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 58px; + min-height: 28px; + border-radius: 999px; + background: #ecfdf5; + color: #16a34a; + font-size: 12px; + font-weight: 900; + white-space: nowrap; +} + +.order-state.is-busy { + background: #fff7ed; + color: #ea580c; +} + +.order-form { + display: grid; + gap: 14px; +} + +.order-safe-text { + margin-bottom: 18px; + color: #7a89a5; + font-size: 15px; + font-weight: 700; +} + +.order-total-box { + position: relative; + display: grid; + gap: 12px; + margin-bottom: 0; + border: 1px solid rgba(245, 158, 11, 0.2); + background: + linear-gradient(135deg, rgba(255, 251, 235, 0.96), rgba(255, 247, 237, 0.92)), #fffbeb; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86); +} + +.order-total-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(217, 119, 6, 0.14); +} + +.order-total-head span { + color: #9a5a16; + font-size: 14px; + font-weight: 900; +} + +.order-total-head strong { + margin: 0; + color: #ef4444; + font-size: 34px; + line-height: 1; + text-align: right; +} + +.order-price-breakdown { + display: grid; + gap: 10px; + padding: 0; +} + +.order-price-breakdown div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.order-price-breakdown span, +.order-deposit-row span { + color: #8a5a12; + font-size: 13px; + font-weight: 900; +} + +.order-price-breakdown strong, +.order-deposit-row strong { + margin: 0; + color: #17233d; + font-size: 16px; + line-height: 1.2; +} + +.order-deposit-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + border-top: 1px solid rgba(217, 119, 6, 0.14); +} + +.order-deposit-row strong { + color: #92400e; + font-size: 18px; +} + +.order-check-list { + display: grid; + gap: 10px; + margin: 0; +} + +.order-check-list div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + min-height: 44px; + border: 1px solid #eef2f7; + border-radius: 12px; + background: #f8fafc; + padding: 0 14px; +} + +.order-check-list dt { + color: #8b9cb5; + font-size: 13px; + font-weight: 900; +} + +.order-check-list dd { + margin: 0; + color: #17233d; + font-size: 14px; + font-weight: 900; + text-align: right; + overflow-wrap: anywhere; +} + +.full-control { + width: 100%; +} + +.order-action-group { + display: grid; + gap: 10px; + padding-top: 2px; +} + +.order-primary-btn { + min-height: 48px; + border: 0; + background: #ff8a00; + color: #ffffff; + font-size: 15px; + font-weight: 900; + box-shadow: 0 12px 24px rgba(255, 106, 0, 0.24); +} + +.order-primary-btn:hover, +.order-primary-btn:focus { + background: #ff7a00; + color: #ffffff; +} + +.order-primary-btn.is-disabled, +.order-primary-btn.is-disabled:hover { + background: #f3a94a; + color: rgba(255, 255, 255, 0.86); + box-shadow: none; +} + +.favorite-order-btn { + min-height: 46px; + margin-left: 0; + border-color: #ff6a00; + background: #ffffff; + color: #ff6a00; + font-size: 15px; + font-weight: 900; +} + +.favorite-order-btn:hover, +.favorite-order-btn:focus { + border-color: #ff6a00; + background: #fff7ed; + color: #ea580c; +} + +.agreement-dialog-body { + display: grid; + gap: 14px; +} + +.agreement-tip { + margin: 0; + color: #64748b; + font-size: 14px; + font-weight: 700; +} + +.agreement-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.agreement-panel { + display: grid; + gap: 10px; + min-width: 0; +} + +.agreement-panel h3 { + margin: 0; + color: #17233d; + font-size: 16px; + font-weight: 900; +} + +.agreement-content { + height: 260px; + overflow: auto; + border: 1px solid #d8dee9; + border-radius: 8px; + background: #f8fafc; + padding: 14px; + color: #1f2937; + font-size: 13px; + font-weight: 700; + line-height: 1.75; + white-space: pre-wrap; +} + +.agreement-read-hint { + color: #f97316; + font-size: 12px; + font-weight: 800; +} + +@media (max-width: 1180px) { + .pc-detail-layout, + .detail-screenshot-grid { + grid-template-columns: 1fr; + } + + .detail-summary-row, + .detail-info-grid, + .detail-resource-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .detail-hero-card, + .detail-hero-card img, + .detail-hero-card > span { + height: 300px; + } + + .detail-hero-overlay { + padding: 24px; + } + + .detail-hero-overlay h1 { + font-size: 26px; + } +} + +@media (max-width: 720px) { + .detail-summary-row, + .detail-info-grid, + .detail-resource-grid { + grid-template-columns: 1fr; + } + + .agreement-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue index eb34b75..5b4edf6 100644 --- a/frontend/src/features/listings/views/ListingDetailView.vue +++ b/frontend/src/features/listings/views/ListingDetailView.vue @@ -630,685 +630,4 @@ function goBack() { - + diff --git a/frontend/src/features/listings/views/MobileListingDetailView.css b/frontend/src/features/listings/views/MobileListingDetailView.css new file mode 100644 index 0000000..0fe21ed --- /dev/null +++ b/frontend/src/features/listings/views/MobileListingDetailView.css @@ -0,0 +1,485 @@ +.mobile-detail { + min-height: 100dvh; + background: #f5f7fa; + padding-bottom: calc(80px + env(safe-area-inset-bottom)); +} + +/* ========== 顶部导航 ========== */ +.page-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + height: 48px; + padding: 0 12px; + background: #fff; + border-bottom: 1px solid #eee; +} + +.page-header h1 { + flex: 1; + margin: 0; + font-size: 17px; + font-weight: 700; + text-align: center; +} + +.back-btn, +.favorite-header-btn { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: none; + background: none; + color: #333; + cursor: pointer; +} + +.favorite-header-btn { + color: #ff6a00; +} + +.header-spacer { + width: 36px; +} + +.center-loading { + display: flex; + justify-content: center; + padding: 60px 0; +} + +/* ========== 防骗提示 ========== */ +.fraud-tip { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: #fff8e1; + font-size: 11px; + color: #e65100; + line-height: 1.4; +} + +/* ========== 封面图 ========== */ +.cover-area { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: #e8e8e8; + overflow: hidden; +} + +.cover-img { + width: 100%; + height: 100%; + cursor: zoom-in; +} + +.cover-img :deep(.el-image__inner) { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cover-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: #999; + font-size: 12px; +} + +.cover-count { + position: absolute; + right: 10px; + bottom: 10px; + padding: 2px 8px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 11px; +} + +/* ========== 信息卡片 ========== */ +.info-card { + margin: 10px 12px; + padding: 14px; + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.info-tag-row { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.mobile-code-chip { + margin-bottom: 8px; + padding: 4px 9px; + border: 1px solid #dbeafe; + border-radius: 999px; + background: #eff6ff; + color: #2563eb; + font-size: 12px; + font-weight: 800; +} + +.mobile-favorite-chip { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 6px; + margin-bottom: 8px; + padding: 4px 9px; + border: 1px solid #fed7aa; + border-radius: 999px; + background: #fff7ed; + color: #ff6a00; + font-size: 12px; + font-weight: 800; +} + +.detail-title { + margin: 0 0 6px; + font-size: 20px; + font-weight: 700; + color: #1a1a1a; + line-height: 1.35; +} + +.detail-desc { + margin: 0; + font-size: 13px; + color: #666; + line-height: 1.5; +} + +/* ========== 资产指标 ========== */ +.metric-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 0 12px; +} + +.metric-item { + background: #fff; + border-radius: 10px; + padding: 10px 4px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.metric-label { + display: block; + font-size: 11px; + color: #999; + margin-bottom: 4px; +} + +.metric-value { + display: block; + overflow: hidden; + font-size: 15px; + color: #1a1a1a; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-value.coin { + color: #1477ff; +} + +.metric-value.price { + color: #ff5f00; +} + +/* ========== 账号资料 ========== */ +.info-line { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #f5f5f5; + font-size: 13px; +} + +.info-line:last-child { + border-bottom: none; +} + +.info-label { + color: #999; +} + +.info-text { + color: #333; + font-weight: 500; +} + +/* ========== 截图列表 ========== */ +.card-subtitle { + margin: 0 0 10px; + font-size: 14px; + font-weight: 700; +} + +.detail-chip-row { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-bottom: 10px; +} + +.detail-chip-row span { + display: inline-flex; + align-items: center; + min-height: 28px; + border: 1px solid #ff8a1f; + border-radius: 5px; + color: #ff7900; + padding: 0 7px; + font-size: 12px; + font-weight: 800; +} + +.resource-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.resource-pill { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 8px; + border-radius: 8px; + background: #f7f9fc; + padding: 10px; +} + +.resource-pill span { + min-width: 0; + color: #5f6b7a; + font-size: 12px; + font-weight: 700; +} + +.resource-pill strong { + color: #17233d; + font-size: 14px; +} + +.resource-pill em { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + grid-column: 1 / -1; + font-size: 11px; + font-style: normal; + font-weight: 800; +} + +.resource-pill em b { + color: #ff7900; +} + +.resource-pill em small { + min-width: 0; + color: #17233d; + font-size: 11px; + font-weight: 800; + text-align: right; +} + +.skin-detail-group + .skin-detail-group { + margin-top: 12px; +} + +.skin-detail-group p { + margin: 0 0 8px; + color: #5f6b7a; + font-size: 12px; + font-weight: 900; +} + +.screenshot-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.screenshot-item { + margin: 0; +} + +.screenshot-thumb { + width: 100%; + aspect-ratio: 4 / 3; + overflow: hidden; + border-radius: 8px; + background: #f8fafc; + cursor: pointer; +} + +.screenshot-thumb :deep(.el-image__inner) { + width: 100%; + height: 100%; + object-fit: cover; +} + +.screenshot-item figcaption { + margin-top: 4px; + color: #6b7280; + font-size: 11px; + font-weight: 700; + text-align: center; +} + +/* ========== 底部留白 ========== */ +.bottom-spacer { + height: 20px; +} + +/* ========== 底部下单栏 ========== */ +.order-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px calc(8px + env(safe-area-inset-bottom)); + background: #fff; + border-top: 1px solid #eee; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04); +} + +.order-bar-left { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.order-price { + display: flex; + align-items: baseline; + gap: 4px; +} + +.price-label { + font-size: 11px; + color: #999; +} + +.price-amount { + font-size: 18px; + font-weight: 900; + color: #ff5f00; +} + +.order-price-detail { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + color: #8a5a12; + font-size: 10px; + font-weight: 700; + line-height: 1.3; +} + +.order-btn { + flex-shrink: 0; + padding: 0 20px; + height: 40px; + font-size: 14px; + font-weight: 700; + background: #ff6a00 !important; + border: none !important; +} + +.agreement-popup { + max-height: 92vh; +} + +.agreement-popup-body { + display: grid; + gap: 12px; + max-height: 92vh; + overflow: auto; + padding: 20px 16px calc(16px + env(safe-area-inset-bottom)); +} + +.agreement-popup-body h3 { + margin: 0; + color: #17233d; + font-size: 18px; + font-weight: 900; +} + +.agreement-popup-body > p { + margin: 0; + color: #6b7280; + font-size: 13px; + font-weight: 700; +} + +.agreement-panel { + display: grid; + gap: 8px; +} + +.agreement-panel > strong { + color: #17233d; + font-size: 14px; +} + +.agreement-content { + height: 190px; + overflow: auto; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; + padding: 12px; + color: #1f2937; + font-size: 12px; + font-weight: 700; + line-height: 1.7; + white-space: pre-wrap; +} + +.agreement-read-hint { + color: #ff6a00; + font-size: 12px; + font-weight: 800; +} + +.agreement-confirm-btn { + margin-top: 4px; + background: #ff6a00 !important; + border: none !important; + font-weight: 800; +} + +/* ========== 空状态 ========== */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 80px 0; + color: #999; + font-size: 14px; +} diff --git a/frontend/src/features/listings/views/MobileListingDetailView.vue b/frontend/src/features/listings/views/MobileListingDetailView.vue index 5ab074a..12bf339 100644 --- a/frontend/src/features/listings/views/MobileListingDetailView.vue +++ b/frontend/src/features/listings/views/MobileListingDetailView.vue @@ -571,490 +571,4 @@ function handleToggleFavorite() { - + diff --git a/frontend/src/features/orders/composables/__tests__/useOrderPaymentCashier.spec.ts b/frontend/src/features/orders/composables/__tests__/useOrderPaymentCashier.spec.ts new file mode 100644 index 0000000..549d251 --- /dev/null +++ b/frontend/src/features/orders/composables/__tests__/useOrderPaymentCashier.spec.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('qrcode', () => ({ + default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr') }, +})) + +import type { PaymentOrder } from '@/features/orders/api/orders' +import { + paymentPayURL, + useOrderPaymentCashier, +} from '@/features/orders/composables/useOrderPaymentCashier' + +function payment(overrides: Partial = {}): PaymentOrder { + return { + id: 1, + payment_no: 'PAY1', + order_id: 2, + order_no: 'ORD1', + provider: 'mock', + third_order_id: 'THIRD1', + provider_order_id: '', + pay_way: 'ZFBZF', + jspay_flag: '2', + amount_cent: 100, + status: 'paying', + paid: false, + created_at: '', + updated_at: '', + ...overrides, + } +} + +function options() { + return { + notifySuccess: vi.fn(), + notifyError: vi.fn(), + notifyInfo: vi.fn(), + notifyFallback: vi.fn(), + queryPayment: vi.fn(), + } +} + +describe('useOrderPaymentCashier', () => { + beforeEach(() => vi.clearAllMocks()) + + it('selects the first available payment URL', () => { + expect(paymentPayURL(payment({ jspay_url: '', td_code: 'https://td' }))).toBe('https://td') + expect(paymentPayURL(payment({ jspay_info: 'https://info' }))).toBe('https://info') + expect(paymentPayURL(null)).toBe('') + }) + + it('handles an already-paid payment and invokes the callback', async () => { + const current = options() + const onPaid = vi.fn() + const cashier = useOrderPaymentCashier(current) + + await cashier.openPaymentCashier(payment({ paid: true }), onPaid) + + expect(current.notifySuccess).toHaveBeenCalledWith('支付成功,正在进入群消息') + expect(onPaid).toHaveBeenCalledOnce() + expect(cashier.paymentPopupVisible.value).toBe(false) + }) + + it('uses fallback feedback for non-http payment URLs', async () => { + const current = options() + const cashier = useOrderPaymentCashier(current) + + await cashier.openPaymentCashier(payment({ jspay_url: 'alipay://pay' })) + + expect(current.notifyFallback).toHaveBeenCalledWith('alipay://pay') + }) + + it('renders a QR code, reports pending status, then handles paid polling result', async () => { + const current = options() + current.queryPayment + .mockResolvedValueOnce(payment({ jspay_url: 'https://pay', paid: false })) + .mockResolvedValueOnce(payment({ paid: true })) + const onPaid = vi.fn() + const cashier = useOrderPaymentCashier({ ...current, pollIntervalMs: 60_000 }) + + await cashier.openPaymentCashier(payment({ jspay_url: 'https://pay' }), onPaid) + expect(cashier.paymentQRCodeURL.value).toContain('data:image') + await cashier.refreshPaymentStatus() + expect(current.notifyInfo).toHaveBeenCalledWith('支付暂未完成') + await cashier.refreshPaymentStatus() + expect(current.notifySuccess).toHaveBeenCalled() + expect(onPaid).toHaveBeenCalledOnce() + cashier.stopPaymentPolling() + }) + + it('reports QR rendering errors without leaving the generating state set', async () => { + const current = options() + const cashier = useOrderPaymentCashier(current) + const qrcode = await import('qrcode') + vi.mocked(qrcode.default.toDataURL).mockRejectedValueOnce(new Error('render failed')) + + await cashier.openPaymentCashier(payment({ jspay_url: 'https://pay' })) + + expect(current.notifyError).toHaveBeenCalledWith('二维码生成失败') + expect(cashier.qrGenerating.value).toBe(false) + cashier.stopPaymentPolling() + }) +}) diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.css b/frontend/src/features/orders/views/MobileOrderDetailView.css new file mode 100644 index 0000000..5869495 --- /dev/null +++ b/frontend/src/features/orders/views/MobileOrderDetailView.css @@ -0,0 +1,259 @@ +.mobile-order-detail-view { + min-height: 100dvh; + background: #f5f7fa; + padding-bottom: 40px; +} + +/* ========== Header ========== */ +.page-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + height: 48px; + padding: 0 12px; + background: #fff; + border-bottom: 1px solid #eee; +} + +.page-header h1 { + flex: 1; + margin: 0; + font-size: 17px; + font-weight: 700; + text-align: center; +} + +.back-btn, +.chat-btn { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: none; + background: none; + color: #333; + cursor: pointer; +} + +.chat-btn:disabled { + color: #a1a1aa; +} + +.header-spacer { + width: 36px; +} + +.center-loading { + display: flex; + justify-content: center; + padding: 60px 0; +} + +/* ========== Card layout ========== */ +.card-section { + margin: 12px; + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + overflow: hidden; + padding: 14px; +} + +.info-card { + padding: 16px; +} + +.card-header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.order-no { + font-size: 12px; + color: #999; + font-family: monospace; +} + +.listing-code-chip { + margin-bottom: 10px; + padding: 4px 9px; + border: 1px solid #dbeafe; + border-radius: 999px; + background: #eff6ff; + color: #2563eb; + font-size: 12px; + font-weight: 800; +} + +.order-title { + font-size: 18px; + font-weight: 700; + color: #1a1a1a; + margin: 0 0 14px 0; +} + +.order-meta-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + background: #f8f9fa; + border-radius: 8px; + padding: 10px; +} + +.meta-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.meta-label { + font-size: 11px; + color: #8c96a0; + margin-bottom: 4px; +} + +.meta-value { + font-size: 13px; + color: #2c3e50; + font-weight: 600; +} + +.meta-value.accent { + color: #ff5f00; + font-weight: 700; +} + +.meta-value.income { + color: #16a34a; + font-weight: 700; +} + +.meta-note { + margin-top: 2px; + font-size: 10px; + color: #2563eb; +} + +.action-card { + padding: 16px; +} + +.section-title { + font-size: 15px; + font-weight: 700; + color: #2c3e50; + margin: 0 0 8px 0; +} + +.action-hint { + font-size: 12px; + color: #7f8c8d; + line-height: 1.5; + margin: 0 0 14px 0; +} + +.action-buttons { + display: flex; + flex-direction: column; + gap: 10px; +} + +.action-field { + background: #f8fafc; + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; +} + +.custom-inline-input { + border: none; + background: transparent; + outline: none; + /* iOS Safari:font-size < 16px 时聚焦会自动放大页面 */ + font-size: 16px; + flex: 1; +} + +.no-snapshot-hint { + text-align: center; + padding: 10px; + color: #cbd5e0; + font-size: 13px; +} + +/* ========== Popups ========== */ +.mobile-popup-form { + max-height: 85%; + padding-bottom: env(safe-area-inset-bottom); +} + +.popup-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #edf2f7; +} + +.popup-header h3 { + margin: 0; + font-size: 16px; + font-weight: 700; +} + +.popup-close { + border: none; + background: transparent; + font-size: 16px; + color: #a0aec0; + cursor: pointer; +} + +.popup-body { + padding: 16px; + overflow-y: auto; +} + +.form-hint-info { + font-size: 12px; + color: #718096; + line-height: 1.5; + margin: 0 0 12px 0; +} + +.popup-field { + background: #f7fafc; + border-radius: 8px; + margin-bottom: 12px; +} + +.evidence-upload-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + color: #4a5568; + margin-bottom: 16px; +} + +.popup-submit { + margin-top: 10px; +} + +.custom-select { + border: none; + background: transparent; + width: 100%; + font-size: 14px; + color: #2d3748; + outline: none; +} + +.empty-wrap { + padding: 60px 0; +} diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index e92194f..a3d1e62 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -660,264 +660,4 @@ async function copyListingCode() { - + diff --git a/frontend/src/features/orders/views/OrderDetailView.css b/frontend/src/features/orders/views/OrderDetailView.css new file mode 100644 index 0000000..b66e5cf --- /dev/null +++ b/frontend/src/features/orders/views/OrderDetailView.css @@ -0,0 +1,773 @@ +.order-detail-page { + max-width: 1280px; + margin: 0 auto; +} + +.page-header { + margin-bottom: 18px; +} + +.page-header.centered { + max-width: 940px; + margin-right: auto; + margin-left: auto; +} + +.header-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.header-info { + flex: 1; +} + +.header-info h1 { + margin: 4px 0 0; + font-size: 26px; + line-height: 1.25; +} + +.order-meta { + color: #64748b; + margin: 6px 0 10px; +} + +.content-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 16px; + align-items: start; +} + +.content-layout.centered { + grid-template-columns: minmax(0, 1fr); + max-width: 940px; + margin: 0 auto; +} + +.main-content { + min-width: 0; +} + +.action-sidebar { + position: sticky; + top: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.sidebar-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05); +} + +.sidebar-card.highlight-card { + background: #f8fbff; + border-color: #3b82f6; +} + +.sidebar-title { + font-size: 14px; + font-weight: 700; + color: #1f2937; + margin: 0; +} + +.sidebar-hint { + margin: 0; + font-size: 12px; + color: #64748b; + line-height: 1.45; +} + +.sidebar-card .el-button { + width: 100%; +} + +.support-dispute-icon { + display: grid; + flex: none; + width: 32px; + height: 32px; + place-items: center; + border-radius: 8px; + background: #fff1d6; + color: #d97706; + font-size: 18px; +} + +.support-dispute-copy { + display: grid; + gap: 2px; + min-width: 0; + flex: 1; +} + +.support-dispute-copy strong { + color: #1f2937; + font-size: 14px; + line-height: 1.3; +} + +.support-dispute-copy span { + color: #7c5a16; + font-size: 12px; + line-height: 1.45; +} + +.order-progress-section { + margin-bottom: 12px; + padding: 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.metric-card { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 86px; + padding: 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; + transition: all 0.2s; +} + +.metric-card:hover { + border-color: #cbd5e1; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.metric-card.primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + color: #ffffff; +} + +.metric-card.primary .metric-label { + color: rgba(255, 255, 255, 0.9); +} + +.metric-card.primary .metric-value { + color: #ffffff; +} + +.metric-card.highlight { + background: linear-gradient(135deg, #ff6a00 0%, #ee0979 100%); + border: none; + color: #ffffff; +} + +.metric-card.highlight .metric-label { + color: rgba(255, 255, 255, 0.9); +} + +.metric-card.highlight .metric-value { + color: #ffffff; +} + +.metric-card.income { + background: #f0fdf4; + border-color: #bbf7d0; +} + +.metric-card.income .metric-label { + color: #15803d; +} + +.metric-card.income .metric-value { + color: #16a34a; +} + +.metric-label { + font-size: 12px; + color: #64748b; + font-weight: 500; +} + +.metric-value { + font-size: 20px; + font-weight: 700; + color: #1f2937; + line-height: 1.2; +} + +.metric-value.amount { + color: #ff6a00; +} + +.metric-note { + font-size: 12px; + color: #2563eb; +} + +.info-section { + margin-bottom: 16px; +} + +.info-card { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 14px 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0; +} + +.info-label { + font-size: 13px; + color: #64748b; + font-weight: 500; +} + +.info-value { + font-size: 13px; + color: #1f2937; + font-weight: 600; + text-align: right; +} + +.action-card { + margin-top: 12px; + padding: 14px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.action-card.warning { + background: #fffbeb; + border-color: #fde68a; +} + +.action-message { + margin: 0 0 12px 0; + color: #1f2937; + font-size: 14px; +} + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.section-header.compact span { + color: #64748b; + font-size: 12px; +} + +.section-header h2 { + font-size: 18px; + font-weight: 700; + color: #1f2937; + margin: 0; +} + +.form-section { + margin-bottom: 20px; +} + +.form-card { + display: grid; + gap: 12px; + padding: 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.handoff-action-section { + margin-bottom: 14px; +} + +.handoff-action-card { + display: grid; + grid-template-columns: minmax(0, 1fr) 160px; + gap: 12px; + align-items: stretch; + padding: 16px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%); +} + +.handoff-action-card .el-button { + height: 100%; + min-height: 96px; +} + +.handoff-confirm-strip { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; + padding: 16px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%); +} + +.handoff-confirm-strip div { + display: grid; + gap: 4px; + flex: 1; + min-width: 0; +} + +.handoff-confirm-strip strong { + color: #1f2937; + font-size: 14px; +} + +.handoff-confirm-strip span { + color: #475569; + font-size: 12px; + line-height: 1.45; +} + +.secondary-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.secondary-action-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 8px; +} + +.secondary-action-card > div { + display: grid; + gap: 2px; + min-width: 0; + flex: 1; +} + +.secondary-action-card strong { + color: #1f2937; + font-size: 13px; + line-height: 1.3; +} + +.secondary-action-card span { + color: #64748b; + font-size: 12px; + line-height: 1.45; +} + +.secondary-action-card.danger { + border: 1px solid #fecaca; + background: #fff7f7; +} + +.secondary-action-card.support { + border: 1px solid #f8d99b; + background: #fffaf0; +} + +.form-hint { + margin: 0; + padding: 10px 12px; + background: #f8fafc; + border-left: 3px solid #3b82f6; + color: #475569; + font-size: 13px; + line-height: 1.5; + border-radius: 4px; +} + +.section-divider { + display: flex; + align-items: center; + justify-content: center; + margin: 24px 0; + position: relative; +} + +.section-divider::before, +.section-divider::after { + content: ''; + flex: 1; + height: 1px; + background: #e5e7eb; +} + +.section-divider span { + padding: 0 16px; + color: #9ca3af; + font-size: 14px; + font-weight: 500; +} + +.section-divider-mini { + display: flex; + align-items: center; + justify-content: center; + margin: 8px 0; + position: relative; +} + +.section-divider-mini::before, +.section-divider-mini::after { + content: ''; + flex: 1; + height: 1px; + background: #e5e7eb; +} + +.section-divider-mini span { + padding: 0 12px; + color: #9ca3af; + font-size: 12px; + font-weight: 500; +} + +.sub-title { + font-size: 15px; + font-weight: 600; + color: #374151; + margin: 0 0 6px 0; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; +} + +.field-hint { + display: block; + margin-top: 6px; + color: #6b7280; + font-size: 12px; +} + +.dispute-dialog-body { + display: grid; + gap: 12px; +} + +.dispute-dialog-tip { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 12px; + border: 1px solid #f8d99b; + border-radius: 8px; + background: #fffaf0; +} + +.dispute-dialog-tip p { + margin: 0; + color: #7c5a16; + font-size: 13px; + line-height: 1.5; +} + +.dispute-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.upload-line { + padding: 12px; + background: #f9fafb; + border: 1px dashed #cbd5e1; + border-radius: 6px; +} + +.upload-line input[type='file'] { + width: 100%; + font-size: 14px; +} + +:global(.dispute-dialog) { + border-radius: 12px; +} + +:global(.dispute-dialog .el-dialog__body) { + padding: 14px 20px 8px; +} + +:global(.dispute-dialog .el-dialog__footer) { + padding: 12px 20px 18px; +} + +.pay-dialog-body { + display: grid; + gap: 20px; +} + +.pay-way-tip { + margin: 0 0 16px 0; + color: #64748b; + font-size: 14px; +} + +.pay-way-options { + display: grid; + gap: 12px; +} + +.pay-way-option { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 18px; + background: #ffffff; + border: 2px solid #e5e7eb; + border-radius: 12px; + cursor: pointer; + text-align: left; + transition: all 0.18s ease; +} + +.pay-way-option:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); +} + +.pay-way-icon { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 12px; + color: #ffffff; + flex-shrink: 0; +} + +.pay-way-option.wechat:hover { + border-color: #07c160; +} + +.pay-way-option.wechat .pay-way-icon { + background: #07c160; +} + +.pay-way-option.alipay:hover { + border-color: #1677ff; +} + +.pay-way-option.alipay .pay-way-icon { + background: #1677ff; +} + +.pay-way-text { + display: flex; + flex-direction: column; + gap: 3px; +} + +.pay-way-text strong { + font-size: 16px; + font-weight: 600; + color: #1f2937; +} + +.pay-way-text small { + font-size: 13px; + color: #94a3b8; +} + +:global(.pay-way-dialog) { + border-radius: 16px; +} + +:global(.pay-way-dialog .el-dialog__header) { + padding: 20px 24px 0; +} + +:global(.pay-way-dialog .el-dialog__body) { + padding: 16px 24px 24px; +} + +.pay-summary { + padding: 16px 20px; + background: #f8fafc; + border-radius: 8px; +} + +.pay-summary-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.pay-summary-row span { + color: #64748b; + font-size: 14px; +} + +.pay-summary-row strong { + color: #111a44; + font-size: 28px; + font-weight: 700; +} + +.pay-qr-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 24px; + background: #f8fafc; + border-radius: 12px; +} + +.pay-qr-box { + width: 240px; + height: 240px; + display: grid; + place-items: center; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.pay-qr-box img { + width: 220px; + height: 220px; + display: block; +} + +.pay-instructions { + text-align: center; +} + +.pay-instructions h3 { + margin: 0 0 8px 0; + color: #111a44; + font-size: 18px; + font-weight: 600; +} + +.pay-instructions p { + margin: 0; + color: #64748b; + font-size: 14px; + line-height: 1.7; +} + +.pay-hint { + margin: 0; + padding: 16px; + background: #f8fafc; + color: #64748b; + font-size: 14px; + text-align: center; + border-radius: 8px; +} + +.pay-dialog-footer { + display: flex; + justify-content: center; + padding-top: 8px; +} + +:global(.order-pay-dialog) { + border-radius: 16px; +} + +:global(.order-pay-dialog .el-dialog__header) { + padding: 20px 24px; + border-bottom: 1px solid #e5e7eb; +} + +:global(.order-pay-dialog .el-dialog__body) { + padding: 24px; +} + +:global(.order-pay-dialog .el-dialog__footer) { + padding: 16px 24px; + border-top: 1px solid #e5e7eb; +} + +@media (max-width: 768px) { + .content-layout { + grid-template-columns: 1fr; + } + + .action-sidebar { + position: static; + order: -1; + } + + .header-top { + flex-direction: column; + } + + .detail-grid { + grid-template-columns: repeat(2, 1fr); + } + + .form-grid { + grid-template-columns: 1fr; + } + + .handoff-action-card, + .handoff-confirm-strip, + .secondary-action-card { + grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; + } + + .handoff-action-card .el-button { + min-height: 40px; + } + + .action-buttons { + flex-direction: column; + } + + .action-buttons .el-button { + width: 100%; + } + + .pay-qr-section { + padding: 16px; + } +} diff --git a/frontend/src/features/orders/views/OrderDetailView.vue b/frontend/src/features/orders/views/OrderDetailView.vue index 93d5c8f..9a5c690 100644 --- a/frontend/src/features/orders/views/OrderDetailView.vue +++ b/frontend/src/features/orders/views/OrderDetailView.vue @@ -823,778 +823,4 @@ watch([() => route.query.focus, order, loading], () => { - + diff --git a/frontend/src/features/orders/views/OrdersView.css b/frontend/src/features/orders/views/OrdersView.css new file mode 100644 index 0000000..0952e8b --- /dev/null +++ b/frontend/src/features/orders/views/OrdersView.css @@ -0,0 +1,396 @@ +.orders-toolbar { + margin-top: 24px; + display: flex; + align-items: center; + gap: 12px; +} + +.search-input { + max-width: 400px; +} + +.order-tabs { + margin-top: 16px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 0 16px; +} + +.order-tabs :deep(.el-tabs__header) { + margin: 0; +} + +.order-tabs :deep(.el-tabs__nav-wrap::after) { + height: 0; +} + +.tab-label { + display: flex; + align-items: center; + gap: 6px; +} + +.tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + background: #ff6a00; + color: #ffffff; + font-size: 12px; + font-weight: 600; +} + +.order-tabs :deep(.is-active) .tab-count { + background: #ffffff; + color: #ff6a00; +} + +.orders-container { + margin-top: 24px; +} + +.empty-state { + margin-top: 60px; +} + +.orders-table { + display: block; +} + +.mobile-cards { + display: none; +} + +.biz-tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.biz-tag.rental { + color: #1477ff; + background: rgba(20, 119, 255, 0.1); +} + +.biz-tag.crash { + color: #ff6a00; + background: rgba(255, 106, 0, 0.1); +} + +.order-no-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.order-no-text { + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + font-size: 13px; + color: #4a5568; +} + +.listing-code-line { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 5px; + margin-top: 10px; + padding: 4px 9px; + border: 1px solid #dbeafe; + border-radius: 999px; + background: #eff6ff; + color: #2563eb; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.listing-code-line.compact { + margin-top: 0; + margin-bottom: 4px; +} + +.copy-icon { + cursor: pointer; + color: #9ca3af; + font-size: 14px; + transition: color 0.2s; +} + +.copy-icon:hover { + color: #ff6a00; +} + +.amount-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +.amount-value { + font-weight: 600; + color: #1f2937; +} + +.amount-label { + font-size: 12px; + color: #9ca3af; +} + +.amount-sub { + font-size: 12px; + color: #10b981; + font-weight: 600; +} + +.status-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.countdown-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + background: #fef3c7; + color: #d97706; + font-size: 11px; + font-weight: 500; +} + +.action-buttons { + display: flex; + gap: 8px; +} + +.muted { + color: #9ca3af; + font-size: 13px; +} + +.crash-product-cell, +.rental-product-cell { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.crash-product-cell { + flex-direction: row; + align-items: center; + gap: 12px; +} + +.crash-cover { + width: 48px; + height: 48px; + border-radius: 10px; + overflow: hidden; + background: #f0f3f8; + display: flex; + align-items: center; + justify-content: center; + color: #a0aec0; + font-size: 12px; + flex-shrink: 0; +} + +.crash-cover.lg { + width: 64px; + height: 64px; +} + +.crash-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.crash-product-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.crash-product-info strong, +.rental-title { + font-size: 14px; + color: #1f2937; +} + +.crash-product-info span { + font-size: 12px; + color: #8a94a6; +} + +.crash-status { + display: inline-flex; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + background: #f1f5f9; + color: #64748b; +} + +.crash-status.pending_payment { + background: #fff7ed; + color: #ff6a00; +} + +.crash-status.paid { + background: #ecfdf5; + color: #059669; +} + +.crash-status.receiving { + background: #fff7ed; + color: #d97706; +} + +.crash-status.completed { + background: #eff6ff; + color: #2563eb; +} + +.crash-status.cancelled, +.crash-status.refunded { + background: #f8fafc; + color: #94a3b8; +} + +.crash-card-body { + display: grid; + grid-template-columns: 64px 1fr; + gap: 12px; +} + +.order-card { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; + background: #ffffff; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 12px; +} + +.order-card:hover { + border-color: #ff6a00; + box-shadow: 0 4px 12px rgba(255, 106, 0, 0.1); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.order-no-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.card-title { + font-size: 16px; + font-weight: 600; + color: #1f2937; + margin-bottom: 12px; +} + +.card-meta { + display: grid; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #f3f4f6; +} + +.meta-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.meta-label { + font-size: 13px; + color: #6b7280; +} + +.meta-value { + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: 14px; + color: #374151; + font-weight: 500; +} + +.meta-value em { + font-style: normal; + font-size: 12px; + color: #2563eb; +} + +.meta-value.amount { + color: #ff6a00; + font-weight: 600; + font-size: 15px; +} + +.card-footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.status-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.status-label { + font-size: 14px; + font-weight: 500; + color: #1f2937; +} + +@media (max-width: 768px) { + .orders-table { + display: none; + } + + .mobile-cards { + display: block; + } + + .search-input { + max-width: 100%; + } + + .order-tabs { + padding: 0 8px; + } + + .order-tabs :deep(.el-tabs__item) { + padding: 0 12px; + font-size: 14px; + } +} diff --git a/frontend/src/features/orders/views/OrdersView.vue b/frontend/src/features/orders/views/OrdersView.vue index ca41b6d..4fec8ce 100644 --- a/frontend/src/features/orders/views/OrdersView.vue +++ b/frontend/src/features/orders/views/OrdersView.vue @@ -586,401 +586,4 @@ function bizTypeLabel(bizType: BizType) { - + diff --git a/frontend/src/features/wallet/views/MobileWithdrawalView.css b/frontend/src/features/wallet/views/MobileWithdrawalView.css new file mode 100644 index 0000000..2e36b65 --- /dev/null +++ b/frontend/src/features/wallet/views/MobileWithdrawalView.css @@ -0,0 +1,484 @@ +.mobile-withdrawal { + --van-primary-color: #ff6a00; + --van-button-primary-background: #ff6a00; + --van-button-primary-border-color: #ff6a00; + --van-radio-checked-icon-color: #ff6a00; + --van-checkbox-checked-icon-color: #ff6a00; + --van-tag-warning-color: #ff6a00; + min-height: 100vh; + padding: 12px 14px 28px; + background: #fff7ed; +} + +.page-header { + display: grid; + grid-template-columns: 40px 1fr 40px; + align-items: center; + height: 44px; + margin-bottom: 12px; +} + +.page-header h1 { + margin: 0; + text-align: center; + font-size: 17px; + color: #101828; +} + +.back-btn, +.refresh-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + border-radius: 18px; + background: #fff; + color: #1f2937; +} + +.refresh-btn { + justify-self: end; +} + +.balance-panel { + display: grid; + gap: 6px; + padding: 18px; + border-radius: 8px; + background: linear-gradient(135deg, #ff8a1f 0%, #ff5a00 100%); + color: #fff; + box-shadow: 0 14px 28px rgba(255, 106, 0, 0.22); +} + +.balance-panel span, +.balance-panel small { + color: rgba(255, 255, 255, 0.72); + font-size: 12px; +} + +.balance-panel strong { + font-size: 31px; + line-height: 1.1; +} + +.withdraw-card, +.records-card { + margin-top: 12px; + padding: 14px; + border-radius: 8px; + background: #fff; +} + +.section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.section-title h2 { + margin: 0; + font-size: 16px; + color: #101828; +} + +.section-title button { + display: inline-flex; + align-items: center; + gap: 4px; + border: 0; + background: transparent; + color: #ff6a00; + font-size: 13px; +} + +.section-title button:disabled { + color: #98a2b3; +} + +.section-title span { + color: #98a2b3; + font-size: 12px; +} + +.center-loading { + display: flex; + justify-content: center; + padding: 28px 0; +} + +.empty-state { + display: grid; + justify-items: center; + gap: 10px; + padding: 24px 0; + color: #98a2b3; +} + +.empty-state p { + margin: 0; +} + +.account-list { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.account-option { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid #eaecf0; + border-radius: 8px; + background: #fff; +} + +.account-option.active { + border-color: #ff6a00; + background: #fff7ed; +} + +.account-select { + display: grid; + grid-template-columns: 34px 1fr auto 20px; + align-items: center; + gap: 8px; + width: 100%; + min-height: 44px; + padding: 0; + border: 0; + background: transparent; + text-align: left; +} + +.account-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 8px; + background: #fff3e8; + color: #ff6a00; +} + +.account-main { + display: grid; + gap: 3px; + min-width: 0; +} + +.account-main strong { + overflow: hidden; + color: #101828; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-main small { + overflow: hidden; + color: #667085; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid #f2f4f7; +} + +.account-actions button { + border: 0; + background: transparent; + color: #ff6a00; + font-size: 12px; +} + +.account-actions button.danger { + color: #b42318; +} + +.account-actions button:disabled { + color: #98a2b3; +} + +:deep(.van-cell) { + padding-right: 0; + padding-left: 0; +} + +.amount-meta, +.amount-preview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 10px 0; + color: #667085; + font-size: 12px; +} + +.amount-meta button { + border: 0; + background: transparent; + color: #ff6a00; +} + +.amount-preview { + padding: 10px 12px; + border-radius: 8px; + background: #f8fafc; +} + +.amount-preview strong { + color: #ff6a00; + font-size: 18px; +} + +.submit-btn { + margin-top: 14px; +} + +.record-list { + display: grid; + gap: 10px; +} + +.record-item { + padding: 12px; + border: 1px solid #eaecf0; + border-radius: 8px; + background: #fff; +} + +.record-top, +.record-meta { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.record-top strong { + display: block; + color: #101828; + font-size: 18px; +} + +.record-top small, +.record-meta { + color: #98a2b3; + font-size: 12px; +} + +.record-meta { + margin-top: 8px; +} + +.record-remark { + margin: 10px 0 0; + color: #b42318; + font-size: 12px; +} + +.cancel-btn { + margin-top: 10px; + padding: 6px 10px; + border: 1px solid #fecdca; + border-radius: 999px; + background: #fff; + color: #b42318; + font-size: 12px; +} + +.account-popup { + max-height: 86vh; +} + +.popup-body { + padding: 20px 16px 24px; +} + +.popup-body h2 { + margin: 0 0 16px; + font-size: 17px; + color: #101828; +} + +.delete-account-btn { + margin-top: 10px; +} + +.type-tabs { + gap: 8px 12px; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.type-tabs :deep(.van-radio) { + margin-right: 0; +} + +.type-tabs :deep(.van-radio__label) { + color: #1f2937; + font-size: 15px; +} + +.type-label { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.recommend-badge { + display: inline-flex; + align-items: center; + height: 17px; + padding: 0 6px; + border-radius: 999px; + background: #fff7ed; + color: #c2410c; + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.type-tip { + margin: 0 0 12px; + color: #8a5a13; + font-size: 12px; + line-height: 1.5; +} + +.account-tip { + margin: 12px 0; + padding: 10px 12px; + border-radius: 8px; + background: #fffbeb; + color: #92400e; + font-size: 12px; + line-height: 1.6; +} + +.receive-qr-tip { + display: grid; + grid-template-columns: minmax(0, 1fr) 108px; + gap: 10px; + align-items: center; + margin: 12px 0; + padding: 10px; + border: 1px solid #fed7aa; + border-radius: 10px; + background: #fff7ed; + color: #92400e; +} + +.receive-qr-tip strong { + display: block; + margin-bottom: 5px; + color: #c2410c; + font-size: 13px; +} + +.receive-qr-tip p { + margin: 0; + font-size: 12px; + line-height: 1.55; +} + +.receive-qr-tip img { + display: block; + width: 108px; + border: 1px solid #fde6c8; + border-radius: 8px; + background: #ffffff; +} + +.certificate-section { + margin: 14px 0; +} + +.certificate-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + color: #1f2937; + font-size: 14px; + font-weight: 600; +} + +.certificate-title small { + color: #98a2b3; + font-size: 12px; + font-weight: 400; +} + +.certificate-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.certificate-item, +.certificate-upload { + position: relative; + overflow: hidden; + aspect-ratio: 1; + border-radius: 8px; + background: #fff7ed; +} + +.certificate-item { + border: 1px solid #fed7aa; +} + +.certificate-item img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.certificate-remove { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 50%; + background: rgba(15, 23, 42, 0.72); + color: #fff; +} + +.certificate-upload { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px dashed #ff9f43; + color: #ff6a00; + font-size: 12px; +} + +.certificate-upload input { + display: none; +} + +.certificate-upload span { + line-height: 1; +} diff --git a/frontend/src/features/wallet/views/MobileWithdrawalView.vue b/frontend/src/features/wallet/views/MobileWithdrawalView.vue index a419f23..24b3b90 100644 --- a/frontend/src/features/wallet/views/MobileWithdrawalView.vue +++ b/frontend/src/features/wallet/views/MobileWithdrawalView.vue @@ -664,489 +664,4 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning - + diff --git a/frontend/src/layouts/AdminLayout.css b/frontend/src/layouts/AdminLayout.css new file mode 100644 index 0000000..7cf5377 --- /dev/null +++ b/frontend/src/layouts/AdminLayout.css @@ -0,0 +1,373 @@ +.admin-shell { + display: flex; + height: 100vh; + min-height: 100vh; + background-color: #f5f7fa; + overflow: hidden; +} + +.admin-sidebar { + width: 220px; + height: 100vh; + flex: none; + background-color: #304156; + transition: width 0.3s ease; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.admin-shell--collapsed .admin-sidebar { + width: 64px; +} + +.admin-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid #3d4e5f; +} + +.admin-brand { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: #fff; + overflow: hidden; +} + +.brand-mark { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background-color: #409eff; + border-radius: 4px; + font-size: 18px; + font-weight: 700; + flex-shrink: 0; +} + +.brand-text { + font-size: 16px; + font-weight: 600; + white-space: nowrap; +} + +.collapse-btn { + color: #bfcbd9; + cursor: pointer; + font-size: 20px; + flex-shrink: 0; +} + +.collapse-btn:hover { + color: #fff; +} + +.admin-menu { + --el-menu-bg-color: transparent; + --el-menu-text-color: #bfcbd9; + --el-menu-active-color: #409eff; + --el-menu-hover-bg-color: #263445; + + flex: 1; + min-height: 0; + border-right: none; + background-color: transparent; + overflow-y: auto; + overflow-x: hidden; +} + +.admin-sidebar-footer { + display: grid; + flex: none; + gap: 10px; + padding: 12px; + border-top: 1px solid #3d4e5f; + background: rgba(16, 24, 39, 0.18); +} + +.admin-shell--collapsed .admin-sidebar-footer { + padding: 10px 8px; +} + +.sidebar-notification { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + min-height: 32px; + padding: 0 10px; + border: 1px solid rgba(64, 158, 255, 0.35); + border-radius: 8px; + color: #d7dfec; + text-decoration: none; + transition: + background-color 0.2s, + border-color 0.2s; +} + +.sidebar-notification:hover, +.sidebar-notification.router-link-active { + background-color: rgba(64, 158, 255, 0.14); + border-color: rgba(64, 158, 255, 0.8); + color: #fff; +} + +.admin-shell--collapsed .sidebar-notification { + padding: 0; +} + +.sidebar-notification-icon { + position: relative; + display: grid; + width: 20px; + height: 20px; + place-items: center; +} + +.sidebar-notification-text { + font-size: 13px; + font-weight: 700; +} + +.sidebar-badge { + position: absolute; + top: -8px; + right: -13px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border: 2px solid #304156; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 10px; + font-style: normal; + font-weight: 800; + line-height: 12px; + text-align: center; + box-sizing: border-box; +} + +.sidebar-status { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + width: 100%; + min-height: 30px; + padding: 0 10px; + border: 1px solid; + border-radius: 8px; + color: #d7dfec; + cursor: pointer; + transition: + background-color 0.2s, + border-color 0.2s; +} + +.sidebar-status:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.admin-shell--collapsed .sidebar-status { + padding: 0; +} + +.sidebar-user { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + width: 100%; + padding: 8px; + border-radius: 10px; + cursor: pointer; + transition: background-color 0.2s; +} + +.sidebar-user:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.admin-shell--collapsed .sidebar-user { + justify-content: center; + padding: 8px 0; +} + +.sidebar-user-text { + display: grid; + min-width: 0; + gap: 2px; +} + +.sidebar-user-text strong { + overflow: hidden; + color: #fff; + font-size: 13px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-user-text small { + overflow: hidden; + color: #8fa0ba; + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-menu:not(.el-menu--collapse) { + width: 100%; +} + +:deep(.el-menu) { + background-color: transparent; +} + +:deep(.el-sub-menu__title) { + color: #bfcbd9; +} + +:deep(.el-sub-menu__title:hover) { + background-color: #263445; +} + +:deep(.el-sub-menu.is-active > .el-sub-menu__title) { + color: #409eff; +} + +:deep(.el-menu-item) { + color: #bfcbd9; +} + +:deep(.el-menu-item:hover) { + background-color: #263445; +} + +:deep(.el-menu-item.is-active) { + color: #409eff; + background-color: #263445; +} + +:deep(.el-menu--inline) { + background-color: #263445; +} + +:deep(.el-menu--inline .el-menu-item) { + height: 46px; + background-color: #263445; + font-size: 14px; +} + +:deep(.el-menu--inline .el-menu-item:hover), +:deep(.el-menu--inline .el-menu-item.is-active) { + background-color: #1f2d3d; +} + +.menu-item-label { + min-width: 0; +} + +.menu-item-badge { + margin-left: auto; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #f43f5e; + color: #ffffff; + font-size: 11px; + font-style: normal; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +:deep(.el-menu--collapse .menu-item-badge) { + position: absolute; + top: 6px; + right: 5px; + min-width: 8px; + width: 8px; + height: 8px; + padding: 0; + border: 2px solid #304156; + border-radius: 50%; + color: transparent; + font-size: 0; + line-height: 0; +} + +:global(.admin-menu-popper) { + --el-menu-bg-color: #304156; + --el-menu-text-color: #bfcbd9; + --el-menu-active-color: #409eff; + --el-menu-hover-bg-color: #263445; +} + +:global(.admin-menu-popper .el-menu) { + border-right: none; + background-color: #304156; +} + +:global(.admin-menu-popper .el-menu-item) { + color: #bfcbd9; +} + +:global(.admin-menu-popper .el-menu-item:hover), +:global(.admin-menu-popper .el-menu-item.is-active) { + color: #409eff; + background-color: #263445; +} + +.admin-workspace { + flex: 1; + min-width: 0; + min-height: 0; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-text { + font-size: 13px; + color: inherit; + font-weight: 500; +} + +:deep(.el-dropdown-menu__item .status-dot) { + margin-right: 8px; +} + +.admin-main { + flex: 1; + min-height: 0; + padding: 18px 24px 24px; + overflow-y: auto; +} + +:global(.force-password-dialog) { + max-width: calc(100vw - 32px); +} + +.force-password-form { + display: grid; + gap: 2px; +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 5b6a348..4019d54 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -542,378 +542,4 @@ async function handleForcedPasswordChange() { - + diff --git a/frontend/src/layouts/AppLayout.css b/frontend/src/layouts/AppLayout.css new file mode 100644 index 0000000..c983d8e --- /dev/null +++ b/frontend/src/layouts/AppLayout.css @@ -0,0 +1,732 @@ +/* ========== 顶部栏 ========== */ +.pc-app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 100vw; + overflow-x: clip; + background: #f5f6f8; +} + +.pc-topbar { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 20px; + min-width: 0; + max-width: 100%; + height: 64px; + padding: 0 24px; + background: #fff; + border-bottom: 1px solid #eef1f5; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); +} + +/* ========== 品牌 ========== */ +.pc-brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} + +.pc-brand-mark { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 12px; + background: linear-gradient(135deg, #fff3e8 0%, #ffe4cc 100%); + color: #ff6a00; + font-size: 20px; + font-weight: 900; +} + +.pc-brand-info { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.pc-brand-text { + font-size: 16px; + font-weight: 800; + color: #17233d; +} + +.pc-brand-sub { + font-size: 11px; + color: #8b9cb5; + font-weight: 600; +} + +/* ========== 导航 ========== */ +.pc-nav { + display: flex; + align-items: center; + gap: 4px; + margin-left: 10px; + min-width: 0; + height: 100%; +} + +.pc-nav-link { + position: relative; + display: flex; + align-items: center; + gap: 5px; + padding: 8px 14px; + border-radius: 10px; + color: #5a6577; + font-size: 14px; + font-weight: 700; + text-decoration: none; + transition: all 0.2s; +} + +.pc-nav-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 10px; + font-style: normal; + font-weight: 800; + line-height: 16px; + text-align: center; +} + +.pc-nav-link:hover, +.pc-nav-link.router-link-active { + background: #fff3e8; + color: #ff6a00; +} + +/* ========== 搜索 ========== */ +.pc-search { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + padding: 0 14px; + height: 40px; + border-radius: 12px; + background: #f4f6f8; + border: 1px solid transparent; + max-width: 320px; + min-width: 180px; + flex: 1; + transition: all 0.2s; +} + +.pc-search:focus-within { + background: #fff; + border-color: #ff6a00; + box-shadow: 0 0 0 4px rgba(255, 106, 0, 0.08); +} + +.pc-search input { + border: none; + outline: none; + background: transparent; + color: #17233d; + font-size: 13px; + width: 100%; +} + +.pc-search input::placeholder { + color: #a0aab6; +} + +.pc-search .el-icon { + color: #a0aab6; + font-size: 16px; + flex-shrink: 0; +} + +/* ========== 用户操作区 ========== */ +.pc-user-actions { + display: flex; + align-items: center; + gap: 12px; + margin-left: 12px; + min-width: 0; + flex-shrink: 0; + height: 100%; +} + +.pc-icon-link { + display: flex; + align-items: center; + gap: 10px; + padding: 0 22px; + height: 40px; + border-radius: 12px; + border: 1px solid #eef1f5; + background: #ffffff; + color: #5a6577; + font-size: 14px; + font-weight: 700; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.pc-icon-link:hover { + background: #fff3e8; + border-color: #ff6a00; + color: #ff6a00; + box-shadow: 0 4px 12px rgba(255, 106, 0, 0.05); +} + +.service-link { + gap: 7px; + min-width: 92px; + justify-content: center; + padding: 0 18px; + border-color: rgba(255, 106, 0, 0.32); + border-radius: 10px; + background: #fff8f2; + color: #ff6a00; + font-size: 14px; + font-weight: 800; +} + +.service-link .el-icon { + width: 18px; + height: 18px; + font-size: 18px; + stroke-width: 2.2; +} + +.service-link:hover { + background: #fff3e8; + border-color: #ff6a00; + box-shadow: 0 6px 16px rgba(255, 106, 0, 0.1); + transform: translateY(-1px); +} + +.pc-login-link { + display: flex; + align-items: center; + gap: 6px; + padding: 0 16px; + height: 40px; + border-radius: 10px; + background: #f4f6f8; + color: #17233d; + font-size: 13px; + font-weight: 700; + text-decoration: none; + transition: all 0.2s; +} + +.pc-login-link:hover { + background: #eaeff5; +} + +.pc-post-link { + display: flex; + align-items: center; + gap: 6px; + padding: 0 18px; + height: 40px; + border-radius: 10px; + background: #ff6a00; + color: #fff; + font-size: 14px; + font-weight: 800; + text-decoration: none; + box-shadow: 0 6px 16px rgba(255, 106, 0, 0.2); + transition: all 0.2s; +} + +.pc-post-link:hover { + background: #e55d00; + transform: translateY(-1px); +} + +/* ========== 已登录 - 用户块 ========== */ +.pc-user-block { + position: relative; + height: 40px; + display: flex; + align-items: center; +} + +.pc-user-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 4px; + height: 36px; + border: none; + border-radius: 999px; + background: #f4f6f8; + cursor: pointer; + transition: all 0.2s; +} + +.pc-user-trigger:hover, +.pc-user-trigger.active { + background: #eaeff5; +} + +.pc-avatar-chip { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 50%; + overflow: hidden; + background: linear-gradient(135deg, #ff6a00 0%, #e55d00 100%); + color: #fff; + font-size: 13px; + font-weight: 800; + flex-shrink: 0; +} + +.pc-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.pc-avatar-text { + line-height: 1; +} + +.pc-user-name { + max-width: 100px; + overflow: hidden; + color: #17233d; + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pc-realname-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 48px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.pc-growth-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 900; + line-height: 1; + white-space: nowrap; +} + +.pc-growth-badge.is-normal { + background: #eef2ff; + color: #4f46e5; +} + +.pc-growth-badge.is-platinum { + background: #eff6ff; + color: #2563eb; +} + +.pc-growth-badge.is-diamond { + background: #ecfeff; + color: #0891b2; +} + +.pc-growth-badge.is-peak { + background: #fff7ed; + color: #ea580c; +} + +.pc-realname-badge.is-verified, +.dropdown-status.is-verified { + background: #ecfdf3; + color: #16a34a; +} + +.pc-realname-badge.is-pending, +.dropdown-status.is-pending { + background: #fff7ed; + color: #f97316; +} + +.pc-realname-badge.is-rejected, +.dropdown-status.is-rejected { + background: #fff1f2; + color: #e11d48; +} + +.pc-realname-badge.is-unverified, +.dropdown-status.is-unverified { + background: #f1f5f9; + color: #64748b; +} + +.arrow-icon { + font-size: 12px; + color: #8b9cb5; + transition: transform 0.3s; +} + +.arrow-icon.rotated { + transform: rotate(180deg); +} + +/* ========== 下拉菜单容器 (Hover Bridge) ========== */ +.pc-user-dropdown-container { + position: absolute; + top: 100%; + right: 0; + z-index: 200; + padding-top: 12px; +} + +.pc-user-dropdown { + width: 388px; + padding: 14px; + border-radius: 14px; + background: #fff; + border: 1px solid #eef1f5; + box-shadow: 0 10px 30px rgba(23, 35, 61, 0.15); +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + border: none; + border-radius: 10px; + background: none; + color: #475569; + font-size: 14px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; +} + +.dropdown-growth-panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 10px; +} + +.dropdown-growth-panel > div { + display: grid; + gap: 5px; + min-width: 0; + min-height: 62px; + padding: 11px 12px; + border-radius: 12px; + background: #f8fafc; +} + +.dropdown-growth-panel span { + color: #8b9cb5; + font-size: 12px; + font-weight: 800; +} + +.dropdown-growth-panel strong { + min-width: 0; + overflow: hidden; + color: #7c3aed; + font-size: 18px; + font-weight: 900; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 50px; + height: 20px; + margin-left: auto; + padding: 0 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.dropdown-item:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateX(4px); +} + +.dropdown-item .el-icon { + font-size: 16px; + color: #94a3b8; + transition: color 0.2s; +} + +.dropdown-item:hover .el-icon { + color: #ff6a00; +} + +.dropdown-item.logout { + color: #ef4444; +} + +.dropdown-item.logout .el-icon { + color: #ef4444; +} + +.dropdown-item.logout:hover { + background: #fff1f0; +} + +.dropdown-divider { + height: 1px; + margin: 12px 6px; + background: #f1f5f9; +} + +.dropdown-basic-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.dropdown-basic-item { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + min-height: 42px; + padding: 0 12px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 13px; + font-weight: 800; + text-decoration: none; + transition: all 0.2s; +} + +.dropdown-basic-item .el-icon { + flex: 0 0 auto; + color: #94a3b8; + font-size: 16px; +} + +.dropdown-basic-item span:not(.dropdown-status) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-basic-item:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateY(-1px); +} + +.dropdown-basic-item:hover .el-icon { + color: #ff6a00; +} + +.dropdown-service-group { + padding: 0 2px; +} + +.dropdown-service-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2px 8px; + color: #17233d; + font-size: 14px; + font-weight: 900; +} + +.dropdown-service-more { + color: #64748b; + font-size: 12px; + font-weight: 800; + text-decoration: none; +} + +.dropdown-service-more:hover { + color: #ff6a00; +} + +.dropdown-service-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.dropdown-service-card, +.dropdown-seller-card { + text-decoration: none; + transition: all 0.2s; +} + +.dropdown-service-card { + display: grid; + min-width: 0; + min-height: 74px; + place-items: center; + gap: 6px; + padding: 8px 4px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.dropdown-service-card .el-icon { + color: #ff6a00; + font-size: 18px; +} + +.dropdown-service-card:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateY(-1px); +} + +.dropdown-seller-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.dropdown-seller-card { + display: grid; + min-width: 0; + min-height: 74px; + place-items: center; + gap: 6px; + padding: 8px 4px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.dropdown-seller-card .el-icon { + color: #ff6a00; + font-size: 18px; +} + +.dropdown-seller-card:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateY(-1px); +} + +.dropdown-seller-card:hover .el-icon { + color: #ff6a00; +} + +/* 下拉动画 */ +.dropdown-enter-active, +.dropdown-leave-active { + transition: + opacity 0.2s ease, + transform 0.2s ease; +} +.dropdown-enter-from { + opacity: 0; + transform: translateY(10px); +} +.dropdown-leave-to { + opacity: 0; + transform: translateY(5px); +} + +/* ========== 主体 ---------- */ +.pc-main { + flex: 1; + min-width: 0; + padding: 20px 24px; + overflow-x: clip; +} + +/* ========== 页脚 ========== */ +.pc-footer { + flex-shrink: 0; + padding: 16px 24px; + background: #fff; + border-top: 1px solid #eef1f5; +} + +.pc-footer-inner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + max-width: 1280px; + margin: 0 auto; + color: #8b9cb5; + font-size: 12px; + line-height: 1.6; +} + +.pc-footer-icp { + color: #8b9cb5; + text-decoration: none; + transition: color 0.2s; +} + +.pc-footer-icp:hover { + color: #5a6577; +} + +.pc-footer-divider { + color: #d8dfe8; +} + +.pc-footer-copyright { + color: #8b9cb5; +} + +@media (max-width: 1280px) { + .pc-growth-badge { + display: none; + } + + .pc-realname-badge { + display: none; + } +} diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 0f6a13f..53a0ec2 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -359,737 +359,4 @@ async function handleSupportClick() { - + diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 1ec8cf8..2c4ee2f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -15,5 +15,22 @@ export default defineConfig({ environment: 'jsdom', globals: true, include: ['src/**/*.{spec,test}.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + include: [ + 'src/features/admin/api/adminPayments.ts', + 'src/features/admin/utils/adminOrderPresentation.ts', + 'src/features/orders/composables/useOrderPaymentCashier.ts', + 'src/features/wallet/api/wallet.ts', + 'src/features/wallet/api/withdrawal.ts', + ], + thresholds: { + statements: 80, + branches: 70, + functions: 80, + lines: 80, + }, + }, }, }) diff --git a/scripts/check-backend-coverage.sh b/scripts/check-backend-coverage.sh new file mode 100755 index 0000000..fb65cde --- /dev/null +++ b/scripts/check-backend-coverage.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKEND_DIR="${ROOT_DIR}/backend" +PROFILE="$(mktemp -t hfb-backend-cover.XXXXXX)" +OUTPUT="$(mktemp -t hfb-backend-cover-output.XXXXXX)" +trap 'rm -f "${PROFILE}" "${OUTPUT}"' EXIT + +cd "${BACKEND_DIR}" +go test -p 1 -count=1 -coverprofile="${PROFILE}" ./... | tee "${OUTPUT}" + +coverage_for_package() { + local package="$1" + awk -v package="${package}" ' + $1 == "ok" && $2 == package { + for (i = 1; i <= NF; i++) { + if ($i == "coverage:") { + value = $(i + 1) + gsub(/%/, "", value) + print value + exit + } + } + } + ' "${OUTPUT}" +} + +require_at_least() { + local label="$1" + local actual="$2" + local minimum="$3" + if [[ -z "${actual}" ]]; then + printf '[coverage] 无法读取 %s 覆盖率\n' "${label}" >&2 + return 1 + fi + if ! awk -v actual="${actual}" -v minimum="${minimum}" 'BEGIN { exit !(actual + 0 >= minimum + 0) }'; then + printf '[coverage] %s 覆盖率 %s%%,低于门禁 %s%%\n' "${label}" "${actual}" "${minimum}" >&2 + return 1 + fi + printf '[coverage] %s %s%%(门禁 %s%%)\n' "${label}" "${actual}" "${minimum}" +} + +total="$(go tool cover -func="${PROFILE}" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')" +require_at_least "后端总语句" "${total}" "30.0" +require_at_least "order" "$(coverage_for_package hfb_sys/backend/internal/modules/order)" "40.0" +require_at_least "payment" "$(coverage_for_package hfb_sys/backend/internal/modules/payment)" "20.0" +require_at_least "wallet" "$(coverage_for_package hfb_sys/backend/internal/modules/wallet)" "34.0" +require_at_least "withdrawal" "$(coverage_for_package hfb_sys/backend/internal/modules/withdrawal)" "30.0" diff --git a/scripts/check-vue-size.sh b/scripts/check-vue-size.sh new file mode 100755 index 0000000..275c0ed --- /dev/null +++ b/scripts/check-vue-size.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MAX_LINES="${VUE_MAX_LINES:-1800}" +failed=0 +while IFS= read -r -d '' file; do + lines="$(wc -l < "${file}")" + if (( lines > MAX_LINES )); then + printf '[vue-size] %s %s 行,超过上限 %s\n' "${file#${ROOT_DIR}/}" "${lines}" "${MAX_LINES}" >&2 + failed=1 + fi +done < <(find "${ROOT_DIR}/frontend/src" -name '*.vue' -print0) + +if (( failed != 0 )); then + exit 1 +fi +printf '[vue-size] 所有 Vue 页面均不超过 %s 行\n' "${MAX_LINES}" diff --git a/scripts/check.sh b/scripts/check.sh index 9329876..a2d7876 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -97,7 +97,7 @@ if [[ "${SKIP_BACKEND}" == "0" ]]; then run_step "go build ./..." go build ./... || FAILED=1 if [[ "${SKIP_TESTS}" == "0" ]]; then # -p 1 串行避免 SQLite 内存库并发; 真实 MySQL e2e 默认 skip - run_step "go test ./..." go test -p 1 -count=1 ./... || FAILED=1 + run_step "go test + coverage" bash "${ROOT_DIR}/scripts/check-backend-coverage.sh" || FAILED=1 else log_warn "跳过后端测试 (--skip-tests)" fi @@ -112,6 +112,8 @@ if [[ "${SKIP_FRONTEND}" == "0" ]]; then log "前端检查目录: ${FRONTEND_DIR}" cd "${FRONTEND_DIR}" + run_step "Vue 页面大小门禁" bash "${ROOT_DIR}/scripts/check-vue-size.sh" || FAILED=1 + if [[ ! -d node_modules ]]; then run_step "npm ci (首次安装依赖)" npm ci || FAILED=1 fi @@ -119,7 +121,7 @@ if [[ "${SKIP_FRONTEND}" == "0" ]]; then # npm run check = prettier --check + eslint --max-warnings=0 + vue-tsc run_step "npm run check (prettier + eslint + typecheck)" npm run check || FAILED=1 if [[ "${SKIP_TESTS}" == "0" ]]; then - run_step "npm run test (vitest)" npm run test || FAILED=1 + run_step "npm run test + coverage (vitest)" npm run test:coverage || FAILED=1 else log_warn "跳过前端测试 (--skip-tests)" fi diff --git a/scripts/extract-vue-styles.sh b/scripts/extract-vue-styles.sh new file mode 100755 index 0000000..6b336ca --- /dev/null +++ b/scripts/extract-vue-styles.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# 将大型 SFC 的内联 scoped CSS 外置,保留 ##s" "${file}" +done < <(find "${ROOT_DIR}/frontend/src" -name '*.vue' -print0) From e418a4dfb135260e0e84d8f8ecda7ee7a4a18906 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 3 Sep 2026 20:06:28 +0800 Subject: [PATCH 59/70] =?UTF-8?q?=E4=B8=8B=E8=B5=9B=E5=AD=A3=E9=80=9A?= =?UTF-8?q?=E8=A1=8C=E8=AF=81=E9=80=89=E9=A1=B9=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/systemconfig/dto.go | 33 +- .../modules/systemconfig/publish_options.go | 13 +- docs/商家余额分账/0.商家余额分账-介绍.md | 347 ++++++++++++++++++ docs/商家余额分账/1.余额分账申请.md | 243 ++++++++++++ docs/商家余额分账/2. 余额分账结果查询.md | 224 +++++++++++ docs/商家余额分账/3.余额分账撤销.md | 224 +++++++++++ docs/商家余额分账/产品介绍.md | 163 ++++++++ .../admin/components/PublishOptionsDialog.vue | 7 + .../features/listings/api/listingOptions.ts | 3 + .../seller/composables/usePublishForm.ts | 6 +- .../views/MobileSellerListingCreateView.vue | 8 +- .../seller/views/SellerListingCreateView.vue | 3 +- 12 files changed, 1249 insertions(+), 25 deletions(-) create mode 100644 docs/商家余额分账/0.商家余额分账-介绍.md create mode 100644 docs/商家余额分账/1.余额分账申请.md create mode 100644 docs/商家余额分账/2. 余额分账结果查询.md create mode 100644 docs/商家余额分账/3.余额分账撤销.md create mode 100644 docs/商家余额分账/产品介绍.md diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 88534e4..4211b3f 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -83,22 +83,23 @@ type HomeBannerItem struct { } type PublishOptionsDTO struct { - ServerOptions []string `json:"server_options"` - FaceOptions []string `json:"face_options"` - RankOptions []string `json:"rank_options"` - InsuranceOptions []string `json:"insurance_options"` - LevelOptions []string `json:"level_options"` - LoginMethodOptions []string `json:"login_method_options"` - RegionOptions []string `json:"region_options"` - SkinGroups []PublishOptionGroup `json:"skin_groups"` - QuantityItems []PublishQuantityItem `json:"quantity_items"` - ScreenshotSlots []PublishScreenshotSlot `json:"screenshot_slots"` - BanRecordOptions []string `json:"ban_record_options"` - BanEvidenceOptions []string `json:"ban_evidence_options"` - FireLevelMin int `json:"fire_level_min"` - PriceConfig PublishPriceConfig `json:"price_config"` - DepositRecommend PublishDepositRecommend `json:"deposit_recommend_config"` - RatioConfig PublishRatioConfig `json:"ratio_config"` + NextSeasonPassEnabled bool `json:"next_season_pass_enabled"` + ServerOptions []string `json:"server_options"` + FaceOptions []string `json:"face_options"` + RankOptions []string `json:"rank_options"` + InsuranceOptions []string `json:"insurance_options"` + LevelOptions []string `json:"level_options"` + LoginMethodOptions []string `json:"login_method_options"` + RegionOptions []string `json:"region_options"` + SkinGroups []PublishOptionGroup `json:"skin_groups"` + QuantityItems []PublishQuantityItem `json:"quantity_items"` + ScreenshotSlots []PublishScreenshotSlot `json:"screenshot_slots"` + BanRecordOptions []string `json:"ban_record_options"` + BanEvidenceOptions []string `json:"ban_evidence_options"` + FireLevelMin int `json:"fire_level_min"` + PriceConfig PublishPriceConfig `json:"price_config"` + DepositRecommend PublishDepositRecommend `json:"deposit_recommend_config"` + RatioConfig PublishRatioConfig `json:"ratio_config"` } type PublishOptionGroup struct { diff --git a/backend/internal/modules/systemconfig/publish_options.go b/backend/internal/modules/systemconfig/publish_options.go index fcc862d..4a5809f 100644 --- a/backend/internal/modules/systemconfig/publish_options.go +++ b/backend/internal/modules/systemconfig/publish_options.go @@ -14,12 +14,13 @@ func defaultPublishOptionsConfigValue() string { func DefaultPublishOptions() PublishOptionsDTO { return PublishOptionsDTO{ - ServerOptions: []string{"QQ", "微信", "Steam"}, - FaceOptions: []string{"是", "否"}, - RankOptions: []string{"青铜", "白银", "黄金", "铂金", "钻石", "黑鹰", "三角洲巅峰"}, - InsuranceOptions: []string{"2*1", "2*2", "2*3", "3*3"}, - LevelOptions: []string{"1级", "2级", "3级", "4级", "5级", "6级", "7级"}, - LoginMethodOptions: []string{"扫码", "账号密码"}, + NextSeasonPassEnabled: false, + ServerOptions: []string{"QQ", "微信", "Steam"}, + FaceOptions: []string{"是", "否"}, + RankOptions: []string{"青铜", "白银", "黄金", "铂金", "钻石", "黑鹰", "三角洲巅峰"}, + InsuranceOptions: []string{"2*1", "2*2", "2*3", "3*3"}, + LevelOptions: []string{"1级", "2级", "3级", "4级", "5级", "6级", "7级"}, + LoginMethodOptions: []string{"扫码", "账号密码"}, RegionOptions: []string{ "北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江", "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南", diff --git a/docs/商家余额分账/0.商家余额分账-介绍.md b/docs/商家余额分账/0.商家余额分账-介绍.md new file mode 100644 index 0000000..95bf890 --- /dev/null +++ b/docs/商家余额分账/0.商家余额分账-介绍.md @@ -0,0 +1,347 @@ +# 一、功能简介 +乐刷的商家分账产品,提供多种方式商家发起分账,简单来看分为两种: + +1. 商家直接使用乐刷提供的商户PC后台发起分账; +2. 服务商接入乐刷的商家分账API,商家使用服务商提供的产品来发起分账。其中乐刷提供给服务商的分账API,有3种分账模式,服务商根据自身的分账业务特性和系统能力选择其一: + 1. 订单分账API。发起分账时,需要指明是对哪笔交易订单号发起分账。 + 2. 余额分账API。发起分账时,无需指明哪笔交易订单号分账,服务商根据自身的业务计算出需要分账的汇总资金,直接发起分账。 + 3. 自动分账API。服务商通过接口配置商家的分账规则,乐刷每日根据已设置的规则自动给商家发起分账。(该模式下,一般面向开发能力较弱的服务商,但服务商又需要商家登录由服务商提供的商家平台上操作场景) + +如服务商需要接入接口,若无特殊情况,建议服务商使用订单分账模式。 + +商家使用分账后,为了不混淆业务,一个商家仅可以使用以上三种分账模式中的一种,不可同时使用,且不能再使用乐刷提供的页面发起分账。 + +比如服务商已经接通余额分账的接口,并且给商家提供相应的分账操作页面,则不可以再让同一个商家直接使用乐刷提供的商家分账管理后台进行分账或者不可以让给同一个商家接通订单分账接口进行分账,该点需要服务商特别谨记。 + +以下介绍商家余额分账API产品的功能和操作步骤。 + +## 1. 余额分账功能简介 +商家余额分账功能,主要用于将商家交易成功后的二维码交易订单资金,按照一定的周期和比例,分账给与分账方(即收款商家)有合作关系的第三方,可以是收款商家的服务商、供货商、物流提供商、平台商等。 + +## 2. 余额分账功能使用场景 +(1) 物业租金抵扣 + +物业给商家提供场地收取商家的租金,为了减少经营商家的资金压力,通过商家的交易款抵扣铺租。由于铺租金额每月收取的金额固定且需要优先结清,无法分拆到具体交易订单订单,该类场景下可使用余额分账。 + +(2)设备押金收取 + +设备提供方给商家提供收款设备等产品,由于该类设备具有相应的成本,商家需要向设备商支付设备采购款,并采用先使用后通过支付的模式,该模式下,可通过余额分账进行分账,将前期的交易结算款用于支付设备款。 + +(3)其他合理的业务场景 + +商家的分账业务只要合理、合规,且可以合理的业务分账依据,并且分账的比例控制在合理范围或者分账金额控制在约定范围,也均可以使用该功能进行实现。 + +# 二、商家订单分账功能 +## 1. 接入准备 +(1)服务商完成服务商入网,取得**服务商编号**。 + +服务商入网流程详见: + +(2)服务商开发者获取到**服务商密钥**。 + +目前暂不支持服务商自行获取进件密钥,服务商密钥统一由乐刷的技术支持同学邮件发送给服务商的技术对接人。可在微信对接群联系乐刷的技术支持同事提供。 + +注: + +1)进件、交易、分账等接口使用的秘钥均为同一个秘钥。 + +2)乐刷当前仅支持向一级代理商提供密钥,不支持向二级及以下的代理商提供密钥。 + +3)商户秘钥不可以调用订单分账的接口,仅服务商秘钥(即1级代理商的秘钥)可调用。 + +(3)服务商、商户已完成订单分账功能的开通。 + +服务商、商户 开通分账功能的开通流程,详见:[三、分账开通流程](https://www.yuque.com/leshuazf/merchant_royalty/ynbk080r037yiicq) + +## 2. 接入指南 +### (1)简介 +完成 [接入准备](#tFfVI) 后,开发者可根据下方的接口调用流程快速接入能力。 + +### (2)分账流程 +#### 流程1:商户入网 +目前乐刷仅支持特定场景的商户使用余额分账,当前开放的特定场景为:医美、加油站、娱乐、教育培训。(若服务商有其他场景,需一事一议,提前与对应的销售沟通确认) + + + +目前乐刷提供两种方式供服务商完成商户入网: + +1) API接口:服务商通过接入乐刷提供的商户入网接口,在自有的产品和系统上增加相应的商户入网资料提交入口,完成分账方和分账接收方的入网。商户接口进件见:[商户入网指引](https://www.yuque.com/leshua-jhzf/qrcode_pay/gr9eco) + +注:分账接收方如无需走交易,则无需进行费率配置等操作。 + +2)代理商平台入网:乐刷为服务商提供服务商业务管理平台,包括服务商PC后台和服务商APP两种,服务商收集到商户的资金后,可登录该平台录入并提交资料完成商户入网。 + + + +商户入网需要提供的资料清单如下: + +1. 分账方: + 1. 小微商户:身份证、银行卡、三合照(门店照、店内照、收银台)。 + 2. 个体商户:营业执照、经营者身份证、银行卡、三合照(门店照、店内照、收银台)。 + 3. 企业商户:营业执照、法人身份证、结算银行卡(对私:结算人身份证、结算人银行卡、非法人结算授权书--非法人结算时提供,对公:开户许可证)、三合照(门店照、店内照、收银台)。 +2. 分账接收方(需要进行收单交易): + 1. 小微商户:身份证、银行卡、三合照(门店照、店内照、收银台)。 + 2. 个体商户:营业执照、经营者身份证、银行卡、三合照(门店照、店内照、收银台)。 + 3. 企业商户:营业执照、法人身份证、结算银行卡(对私:结算人身份证、结算人银行卡、非法人结算授权书--非法人结算时提供,对公:开户许可证)、三合照(门店照、店内照、收银台)。 +3. 分账接收方(不需要进行收单交易): + 1. 分账到对私账户。以小微商户入网至乐刷,生成对应的乐刷商户号。 + 1. 具体所需资料要求:身份证、银行卡、三合照(门店照、店内照、收银台)传以下专用图。 + + + + 2. 分账接收方进件时,简称命名为“商户名+分账”,如“张三分账” + 3. 用此套资料进件会被驳回,乐刷商户号不需要通过,只要生成乐刷商户号即可。此商户号无交易权限,不可发起交易,但不影响商户接收分账权限,不影响分账后的结算。 + 2. 分账到对公账户。以企业商户入网至乐刷。 + 1. 具体所需资料要求:营业执照、法人身份证、结算银行卡(对公:开户许可证)、三合照(门店照、店内照、收银台)。 + + + +#### 流程2:商家余额分账流程 +```mermaid +sequenceDiagram +participant A as 分账方(收款商家) +participant B as 分账接收方 +participant C as 服务商 +participant D as 乐刷 + +#--------以下为具体流程---------# +Note right of A:1.分账方开通商家分账 + A->>C: 申请使用分账功能 + alt 纸质协议开通模式 + C->>C:线下收集收款商家的纸质协议并拍照保存 + C->>D:调用“商户开通分账申请(线下协议)”接口/api/share-merchant/accredit + D-->>C:同步返回接口调用结果 + D->>D:乐刷风控人工审核(1-3工作日内),审核通过即给收款商家开通分账 + C->>D:调用“商户开通分账结果查询”接口/api/share-merchant/accreditQuery + D-->>C:同步返回商户当前的审核结果 + + + else 电子协议开通模式 + C->>D:调用“商户开通分账申请(电子协议)”接口/api/share-merchant/elec-contract-accredit + D-->>C:同步返回调用结果及商家H5电子合同签署链接 + C->>A:将乐刷返回的H5电子合同发送给商家进行签署 + A->>D:商家法人在乐刷提供的H5上完成电子合同的签署 + D->>D:商家完成电子合同签署则自动开通分账 + C->>D:调用“商户开通分账结果查询”接口/api/share-merchant/accreditQuery + D-->>C:同步返回商户当前的分账开通结果 + + end + + +Note right of A:2.分账方和分账接收方绑定分账关系 + A->>C: 将分账接收方的名单提供给服务商 + C->>D:调用“分账关系绑定”接口/api/share-merchant/bind + D-->>C:同步返回绑定结果 + + A->>C: 申请终止某些分账接收方的分账 + C->>D:调用“分账关系解绑”接口/api/share-merchant/unbind + D-->>C:同步返回解绑结果 + + C->>D:调用“分账关系绑定查询”接口/api/share-merchant/queryBind + D-->>C:同步返回分账方已绑定对的分账接收方名单 + +Note right of A:3.收款商家进行交易 + A->>C: 申请发起收款(即正常的收款服务) + C->>D:调用“统一下单”接口get_tdcode或者“条码支付”接口upload_authcode,并传参royalty=1 + D-->>C:同步返回支付请求结果 + + C->>D:调用“交易查询”接口query_status查询支付结果 + D-->>C:同步返回交易订单的支付结果 + C->>A:告知/展示交易订单的支付结果 + + +Note right of A:4.分账发起 + A->>C: 申请发起分账(服务商和商户发起分账的流程可由服务商定义) + C->>D:调用“余额分账申请”接口/api/share-merchant/createSumAccredit + D-->>C:同步返回余额分账结果 + + C->>D:【可选】调用“余额分账结果查询”接口/api/share-merchant/querySumAccredit + D-->>C:同步返回订单分账结果 + + C->>D:【可选】调用“余额分账撤销”接口/api/share-merchant/canceSumAccredit,撤销某笔当日发起的余额分账 + D-->>C:同步返回余额分账撤销结果 + +Note right of A:5.分账资金结算出款 + + D->>D:D1日凌晨获取上一自然日发起的余额分账请求数据进行清分【3点为日切点】 + D->>A:D1日早上将分账给分账方的资金一笔结算给分账方 + D->>B:D1日早上将分账给分账接收方的资金一笔结算给分账接收方 + +Note right of A:6.分账对账 + D->>C:D1日早上将分账打款对账单放置乐刷的FTP,供服务商获取并进行对账 + + +``` + +### (3)流程说明 +#### 1)分账方和分账接收方入网 +分账方和分账接收方,均需要作为乐刷的商户户进行入网,获取到对应的乐刷商户号(乐刷商户号由10位数字组成) + +目前乐刷提供两种方式供服务商完成商户入网: + +1) API接口:服务商通过接入乐刷提供的商户入网接口,在自有的产品和系统上增加相应的商户入网资料提交入口,完成分账方和分账接收方的入网。商户接口进件见:[商户入网指引](https://www.yuque.com/leshua-jhzf/qrcode_pay/gr9eco) + +注:分账接收方如无需走交易,则无需进行费率配置等操作。 + +2)代理商平台入网:乐刷为服务商提供服务商业务管理平台,包括服务商PC后台和服务商APP两种,服务商收集到商户的资金后,可登录该平台录入并提交资料完成商户入网。 + +分账接收方如本身无交易的诉求,只需提交商户入网资料并完成商户入网审核即可,无需进行交易费率配置、微信认证等环节。 + +#### 2)分账方开通分账功能 +**① 分账协议签署和配置分账费率** + +分账方(即收款商家)要使用分账功能,需要先给分账方开通分账功能,目前乐刷提供API接口供服务商接入分账方开通分账功能,分账方要开通分账功能,必须与乐刷签署分账协议,明确分账比例、**分账费率**、服务商授权等内容,以保证分账业务的合规性,避免后续产生不必要的资金纠纷。 + +根据协议签署的方式不同,服务商可选择电子协议或者线下协议两种开通方式: + +1. 电子协议,服务商调用乐刷的“[商户开通分账申请(电子协议)](https://www.yuque.com/leshuazf/merchant_royalty/lkels6)”接口,提交分账方和签约比例、分账费率等信息后,乐刷返回相应的电子合同H5链接,服务商将电子合同链接通过自有平台展示、短信等方式给到商家,由商家法人完成验证和签署。 + +注:电子协议的签署只能由商家法人签署,不可由商家法人之外的其他人签署,在签署前,乐刷会通过校验手机号和银行卡是否为商家法人(即姓法人名、法人身份证号、手机号是否一致,或者法人姓名、法人身份证号、银行卡号是否一致)。 + +电子合同签署验证示例: + +(1)手机号验证模式: + + + + + +(2)银行卡验证模式: + + + +注:获取金额成功后,请商户查看自己的银行APP查看收到的打款金额,并在“回填金额”中输入收到的打款金额,并点击“下一步”完成验证。 + + + +2. 线下协议。服务商下载乐刷的线下协议模板,打印提供给商家,完成信息的填写后,盖上商户的公章(不可用个人按手印的方式,如采用该方式将会风控审核环节被驳回),然后调乐刷的“[商户开通分账申请(纸质协议)](https://www.yuque.com/leshuazf/merchant_royalty/mnslzx)”接口,提交商户开通分账申请。 + +商家分账线下协议模板见: + +[特约商户支付服务合作协议--商户分账补充协议.pdf](https://www.yuque.com/attachments/yuque/0/2022/pdf/529841/1658910656501-17b2fbc7-190b-4e9f-be44-c86afc80706a.pdf) + +服务商提交商家的分账开通申请后,后期可通过“[商户分账开通结果查询](https://www.yuque.com/leshuazf/merchant_royalty/vdbp7t)”接口,查询商家的分账开通结果。 + + + +**特别说明:** + +1. 服务商通过接口给分账方开通分账功能时,分账方的最大分账比例不可以超过服务商的分账比例。 +2. 商户的分账服务费率,需要高于服务商的分账成本,且不可高于5%。 +3. **若商户的分账比例或者分账费率需要变更,需要重新调接口进行协议签署,新的分账比例或者分账服务费率需要签署成功且审核通过后生效。** + + + +**② 余额分账开通报备** + +分账方通过接口的方式,提交分账协议后,会自动进入待审核的环节,进入待审核环节后,需要商家同步提供补充资料供乐刷审核方可完成商家余额分账的开通。资料的提交方式为邮件发送。具体要求如下: + +1. 需要提交的资料内容: + 1. 分账业务协议(商户与其分账接收方) + 2. 商户近一个月内的发票、收据(1-2张) + 3. 经营场景视频(需拍摄到清晰的营业执照) + 4. 特殊行业需额外提供对应的经营资质: + 1. 医疗、美容:《医疗机构执业许可证》 + 2. 医疗器械:《医疗器械经营企业许可证》 + 3. 药品:《药品经营许可证》 + 4. 加油站:《成品油批发经营批准证书》或《成品油仓储经营批准证书》或《成品油零售经营批准证书》或《燃气经营许可证》 + 5. 烟酒类:《烟草零售经营许可证》、《酒类经营许可证》 + 6. 物流/快递:《道路运输许可证》、《快递业务经营许可证》 +2. 邮件发送的要求: + 1. 标题:服务商编号_商户编号_商户全程_余额分账开通申请,如:2888568_0000000018_张三科技有限公司_余额分账开通申请 + 2. 发送人:乐刷商务对接人邮箱(具体请咨询对应的乐刷商务) + 3. 抄送人:carriezheng@yeahka.com;v_yoyodu@yeahka.com;rocketfu@yeahka.com;leshuasaas_spm@yeahka.com;support2@yeahka.com + 4. 邮件正文: + +我司服务商编号+服务商名称,现有“商户编号”+“商户全称”,欲使用乐刷提供的余额分账功能,以下为商家的真实业务场景说明: + +一、商家业务场景说明 + +二、商家分账协议(可作为附件提供) + +三、经营场景视频(可作为附件提供) + +四、特殊行业补充资料(可作为附件提供) + +五、近一个月内开设的发票照片(可作为附件提供) + +3. **乐刷审核** + +乐刷风控审核约需2-3个工作日,审核通过后会给商家开通余额分账的功能,并邮件回复发件人。 + +注:目前仅支持医美、娱乐、加油、教育培训行业的商家申请使用余额分账 + +#### 3)分账方和分账接收方建立绑定关系 +分账前需先通过“[分账关系绑定](https://www.yuque.com/leshuazf/merchant_royalty/ch4u7g)”接口,将分账接收方与分账方(即收款商家)建立绑定关系,一个分账方最多可绑定XXX个分账接收方(乐刷商户号维度)。 + + + +若分账方与某分账接收方终止业务合作,并且后续不需要继续向该分账接收方分账,可调用”[分账关系绑定](https://www.yuque.com/leshuazf/merchant_royalty/ixqa6n)”接口解除绑定关系。解除绑定关系后,后续不可以继续向该分账接收方发起分账。解除绑定关系后,可以继续调用“分账关系绑定”接口重新建立绑定关系。 + +服务商可调用”[分账关系绑定查询](https://www.yuque.com/leshuazf/merchant_royalty/yplfec)”接口查询分账方当前绑定的分账接收方名单。 + +#### 4)分账方进行交易 +当前仅支持对二维码交易的订单标记分账,不可对刷卡交易的订单标记分账。 + +需要分账的订单,交易下单时(统一下单接口和条码支付接口),可在下单接口中传入分账标识(royalty=1),将交易资金预先冻结在商户分账方的待分账账本中。 + +注:扣除交易手续费和分账手续费后,实时计入分账方的待分账账本。 + +#### 5)分账方分账余额分账 +分账交易订单交易成功后,该笔订单扣除交易收费和分账手续费后,剩余资金实时入账至分账方的待分账账本中,服务商可通过调用“[余额分账申请](https://www.yuque.com/leshuazf/merchant_royalty/ypx1g8)”接口发起分账请求。 + +分账请求提交后,乐刷会实时将分账方的待分账账本资金转出到分账接收方的待结算账户,并同步返回分账最终处理结果。 + + + +如某笔分账提交错误,可通过“[余额分账撤销](https://www.yuque.com/leshuazf/merchant_royalty/muqq15)”撤回当日(以0点作为日切点)发起的分账请求,撤销后的资金可用于退款或者继续发起分账。 + + + +服务商可通过调用“[余额分账结果查询](https://www.yuque.com/leshuazf/merchant_royalty/dqep7p)”接口,查询某笔余额分账的分账结果。 + +#### 6)分账方和分账方接收方的账资金结算出款 +服务商发起的订单分账,乐刷D1日凌晨进行统一的清分结算,并于D1早上7点左右出款至分账方和分账接收方。 + +结算出款环节无需服务商接入接口。 + +同一个分账接收方有多个分账方分给他时,会将合并一笔出款给到分账接收方。 + +某分账方既涉及自身给给自身,也涉及其他分账接收方分给自身时,所有的分账资金合并一笔出给该分账方。 + +分账方既涉及分账的交易,也涉及非分账的交易时,非分账的交易合并出款,并且不与分账的资金合并出款。 + +#### 7)商户分账数据对账 +乐刷每日早上8点左右,提供打款对账单给服务商进行对账。 + +[分账交易对账单](https://www.yuque.com/leshuazf/merchant_royalty/af71z3) + +[余额分账对账单](https://www.yuque.com/leshuazf/merchant_royalty/imflfs) + +[商家打款对账单](https://www.yuque.com/leshuazf/merchant_royalty/vi01zl) + +#### 8)服务商分润 +由于商户分账订单的出款具有延迟性,乐刷在交易成功后就对分账订单进行计费,因此分账交易订单成功后的次日,乐刷即计算服务商的分账分润(与交易分润分开入账)。 + +服务商可登录乐刷提供的代理商PC后台,在“分润提取”页面中提取分润。 + +注:分润的提取需要开立相应的发票 + +### (4)分账规则说明 +1. D1结算模式下(默认均采用该模式),分账资金为实付资金,交易手续费和分账手续费在入待分账账本前已经扣除,同一个分账接收方或者分账方,在同一周期内有多笔余额分账时,次日将合并一笔打款给商家。 +2. 一次分账请求,不可以超过50个分账接收方,且分账接收方不允许重复。 +3. 发起余额分账时,分账接收方和分账方的结算状态为正常,如有任一方的结算状态为冻结,则会导致分账失败 +4. 分账比例校验公式: (本次分账给分账接收方的分账金额 + 历史已成功分账给分账接收方的分账金额) ÷ (待分账账本总入账金额 - 分账订单历史成功退款金额 ) ≤ 商户分账比例 +5. 凌晨3点前发起的余额分账,可当日出款。举例:5日早上,对4日3:00-24:00和5日0:00-3:00 间发起的余额分账进行出款。 + +### (5)分账订单退款流程 +如服务商商需要对商户的分账订单进行退款,需要调用专门的“[分账交易退款](https://www.yuque.com/leshuazf/merchant_royalty/lo74w3)”接口发起退款。 + +目前仅支持90天内的交易发起退款,退款时,分账方的待分账账户需有足够的资金,若分账方的待分账账户中无足够资金,则无法进行退款。 + +## 3. 开发常见问题 + + + + + diff --git a/docs/商家余额分账/1.余额分账申请.md b/docs/商家余额分账/1.余额分账申请.md new file mode 100644 index 0000000..2c38ba9 --- /dev/null +++ b/docs/商家余额分账/1.余额分账申请.md @@ -0,0 +1,243 @@ +# 1. 接口用途 +该接口用于发起余额分账请求,交易成功后,交易订单的资金扣除交易手续费和分账手续费后,实时入账至分账方的待分账账本,如待分账账本余额足够,可发起余额分账。 + +接口发起的分账中,订单分账、余额分账、自动分账为不同的分账请求模式,同一个商户只允许选择其中的一种。 + +调用该接口前,商户需要开通分账功能,且商户的结算状态正常。 + +# 2. 接口说明 +## (1) 适用对象 + 1. 一级代理商 + +## (2)接口地址 +测试环境地址:无 + +乐刷不对测试环境的交易订单进行清分、入账和出款,请直接在生产环境进行调试。 + +生产环境地址:https://saas-mch.leshuazf.com/api/share-merchant/createSumAccredit + +## (3)测试参数 +无 + +## (4)特别说明 +1. D1结算模式下(默认均采用该模式),分账资金为实付资金,交易手续费和分账手续费在入待分账账本前已经扣除,同一个分账接收方或者分账方,在同一周期内有多笔余额分账时,次日将合并一笔打款给商家。 +2. 一次分账请求,不可以超过50个分账接收方,且分账接收方不允许重复。 +3. 发起余额分账时,分账接收方和分账方的结算状态为正常,如有任一方的结算状态为冻结,则会导致分账失败 +4. 分账比例校验公式: (本次分账给分账接收方的分账金额 + 历史已成功分账给分账接收方的分账金额) ÷ (待分账账本总入账金额 - 分账订单历史成功退款金额 ) ≤ 商户分账比例 +5. 凌晨3点前发起的余额分账,可当日出款。举例:5日早上,对4日3:00-24:00和5日0:00-3:00 间发起的余额分账进行出款。 + +## (5)频率限制 +40次/20秒 + +# 3. 接口请求 +## (1)请求方式 +1. 接口以 https的POST方式进行调用。 +2. 请求头部的 content-type 类型:application/x-www-form-urlencoded + +## (2)请求参数 +### 公共请求参数 +| **参数名称** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| **agentId** | String | M | 服务商编号,由乐刷分配的接入方唯一标识,明文传输。 | "2888568" | +| **version** | String | M | 目前固定值1.0 | "1.0" | +| **reqSerialNo** | String | M | 请求流水号(yyyyMMddHHmmssSSSXXXXX,其中 XXXXX为5位顺序号,禁止使用UUID等无意义数据) | "202207142012591234" | +| **sign** | String | M | 签名串,业务数据明文参与签名后经 Base64 编码的字符串。
签名方法详见[乐刷聚合签名](https://www.yuque.com/docs/share/a57e3853-70e6-445d-9193-e1892594b0ea?# 《聚合业务接口常规签名》) | "ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE" | +| **data** | String | M | 业务数据明文字符串(Json格式字符串,参照各个接口中输入参数说明) | '{\'cmd\': \'balance_allot_create\', \'merchant_id\': \'6763019848\', \'third_id\': \'202207151719300aNoeMhd\', \'amount\': \'1\', \'remark\': \'分账备注\', \'details\': \'[{"user_id":"6763019848","user_type":"2","amount":"1"}]\'}' | + + +### 业务请求参数 +| **参数** | **参数类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| cmd | String | M | 服务名称,本处固定为:balance_allot_create | "balance_allot_create" | +| merchant_id | String | M | 分账方的乐刷商户号 | "8613319257" | +| third_id | String | M | 商户分账单号 | "202207151719300aNoeMhd" | +| amount | String | M | 分账总金额
details中各分账接收方的分账金额汇总需等于本处的金额 | "2" | +| remark | String | O | 分账备注 | "备注说明1" | +| details | String | M | 分账详情,list格式并且需要转化为string,一次请求不可超过50个分账接收方且分账接收方不可重复,列表内字段说明:
1. user_id:分账接收方/分账方的乐刷商户编号
2. user_type:分账接收方类型,此处固定填2
3. amount:分账金额,单位为分 | '[{"user_id":"8613319257","user_type":"2","amount":"1"},{"user_id":"8123016461","user_type":"2","amount":"1"}]' | + + +## (3)请求示例 +```json +{'agentId': '2653276', 'version': '1.0', 'reqSerialNo': '20220805151053', 'sign': 'YWFiMWM5MjU4NDY3MzkwNGFiNjZjZTUwN2UxYjAzNzQ=', 'data': '{\'cmd\': \'balance_allot_create\', \'merchant_id\': \'6763019848\', \'third_id\': \'202207151719300aNoeMhd\', \'amount\': \'1\', \'remark\': \'分账备注\', \'details\': \'[{"user_id":"6763019848","user_type":"2","amount":"1"}]\'}'} + +``` + + + +```plain +https://saas-mch.leshuazf.com/api/share-merchant/createSumAccredit?agentId=2653276&version=1.0&reqSerialNo=20220805151053&sign=YWFiMWM5MjU4NDY3MzkwNGFiNjZjZTUwN2UxYjAzNzQ%3D&data=%7B%27cmd%27%3A+%27balance_allot_create%27%2C+%27merchant_id%27%3A+%276763019848%27%2C+%27third_id%27%3A+%27202207151719300aNoeMhd%27%2C+%27amount%27%3A+%271%27%2C+%27remark%27%3A+%27%E5%88%86%E8%B4%A6%E5%A4%87%E6%B3%A8%27%2C+%27details%27%3A+%27%5B%7B%22user_id%22%3A%226763019848%22%2C%22user_type%22%3A%222%22%2C%22amount%22%3A%221%22%7D%5D%27%7D + +``` + +```plain +curl --location --request POST 'http://t-saas-mch.lepass.cn/api/share-merchant/createSumAccredit' \ +--form 'agentId=2888568' \ +--form 'version=1.0' \ +--form 'reqSerialNo=20220715110526' \ +--form 'sign=ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE=' \ +--form 'data={"third_id":"0000000060402350","merchant_id":"9031015604", \ +"amount":"50","details":[{"user_id":"9031015604","user_type":"2","amount":"50"}]}' +``` + +# 4. 接口响应 +## (1)响应参数 +| **参数** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| respCode | String | M | 网关应答码。
000000,代表网关转发成功,具体的成功需要看retcode和status
其他应答码代表网关转发失败 | "000000" | +| respMsg | String | M | 网关应答说明,成功时返回“成功” | "成功" | +| version | String | M | 版本号 | "1.0" | +| reqSerialNo | String | M | 请求流水号 | "20220715122334" | +| data | Object | O | 业务参数
respCode=000000时返回 | {'third_id': '20220715173529LDxUoE9l', 'amount': '2', 'leshua_id': '2022071563329181703', 'details': '[{"user_id":"8613319257","user_type":"2","amount":"1"},{"user_id":"8123016461","user_type":"2","amount":"1"}]', 'remark': '分账备注', 'merchant_id': '8613319257', 'retmsg': 'success', 'retcode': '0', 'status': '1'}} | +| ┖retcode | String | M | 业务应答码,
0表示业务受理成功,分账成功需要看status
其他表示业务受理失败 | '0' | +| ┖retmsg | String | M | 业务应答说明
业务受理成功时返回“success” | 'success' | +| ┖third_id | String | O | 商户分账单号 | '20220715173529LDxUoE9l' | +| ┖amount | String | O | 分账总金额,单位为分 | "2" | +| ┖leshua_id | String | O | 乐刷分账单号 | "2022071563329181703" | +| ┖remark | String | O | 分账备注 | '分账备注' | +| ┖status | String | O | 分账状态,枚举如下:
0:处理中
1:分账成功
2:分账失败
3:分账撤销中
4:分账撤销成功
5:分账撤销失败
说明:分账成功后,资金次日统一出款 | '1' | +| ┖merchant_id | String | O | 分账方的乐刷商户号 | '8613319257' | +| ┖details | String[] | O | 分账详情,list格式并且转化为string,返回请求时送的details.
列表内字段说明:
1. user_id:分账接收方/分账方的乐刷商户编号
2. user_type:分账接收方类型,此处
3. amount:分账金额,单位为分 | '[{"user_id":"8613319257","user_type":"2","amount":"1"},{"user_id":"8123016461","user_type":"2","amount":"1"}]' | + + +## (2)响应示例(正常) +```json +{"version":"1.0","respCode":"000000","respMsg":"成功","reqSerialNo":"20220805151053","data":{"third_id":"202207151719300aNoeMhd","amount":"1","leshua_id":"2022080554653269903","details":"[{\"user_id\":\"6763019848\",\"user_type\":\"2\",\"amount\":\"1\"}]","remark":"分账备注","merchant_id":"6763019848","retmsg":"success","retcode":"0","status":"1"}} + +``` + +## (3)响应示例(异常) +```json +{'version': '1.0', 'respCode': '000037', 'respMsg': '分账商户未绑定', 'reqSerialNo': '20220715172759'} +``` + +# 5. 错误码 +**respCode 错误码** + +| **错误码(respCode)** | **错误描述(****respMsg****)** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| 000001 | 必填参数为空 | 乐刷聚合 | 标注为“M”的参数值为空 | 提供为“M”的参数值 | +| 000002 | 签名验证失败 | 乐刷聚合 | 请求方提供的sign参数值为错误的签名 | 1. 请确认服务商编号正确且已生成KEY 2. 请求方参照签名算法,修正sign参数值 | +| 000017 | 渠道商未开通此业务 | 乐刷聚合 | 当前服务商为开通该业务 服务商不属于一级服务商,暂未开通该业务 | 联系运营人员 | +| 000026 | 已暂停代理商进件 | 乐刷聚合 | 当前服务商进件商户权限已被关闭 | 联系运营人员 | +| 000027 | 商户已禁用分账功能 | 乐刷聚合 | 该商户分账权限已被关闭 | 联系运营人员 | +| 000004 | 接口调用频率限制 | 乐刷聚合 | 当前服务商调用接口过于频繁 | 降低接口调用频率 | +| 000005 | json格式或者数据类型不正确 | 乐刷聚合 | data 不是一个合法的json字符串或者字段类型不匹配 | 参照文档修正data值 | +| 000051 | 商户号不正确 | 乐刷聚合 | 商户号在乐刷不存在或者不属于该服务商 | 检查修正商户号 | + + +**respCode =000000时,业务错误码retcode如下:** + +| **错误码(retcode)** | **错误描述(****retmsg****)** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| -55001 | 分账请求中转失败 | 乐刷底层 | 可能调用上游失败 | 重试失败后联系乐刷运营 | +| -55002 | 不存在商户分账号 | 乐刷底层 | 商户号不存在或为空 | 检查商户号 | +| -55003 | 商户分账号重复 | 乐刷底层 | 多个分账商户号中有重复的商户号 | 检查商户号 | +| -55004 | 创建乐刷分账号失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55005 | 禁止分账 | 乐刷底层 | 商户被禁止分账 | 联系乐刷运营 | +| -55006 | 可分账余额小于等于零 | 乐刷底层 | 分账余额不足 | 检查是否已分账过,或重复分账, 或分账金额计算是否有误 | +| -55007 | 分账比例异常 | 乐刷底层 | 合同配置的分账比例非法 | 联系乐刷运营 | +| -55008 | 订单已分账或商户分账号重复 | 乐刷底层 | 订单已分账或商户分账号重复 | 检查分账订单是否已分账 | +| -55009 | 分账方数量限制 | 乐刷底层 | 分账方数量限制 | 检查分账方数量,不能超过50 | +| -55010 | 未查询到该订单,请检查后重试 | 乐刷底层 | 订单不存在 | 检查订单号 | +| -55011 | 不是分账订单 | 乐刷底层 | 订单不是分账订单,不能分账 | 检查订单号对应的订单类型 | +| -55012 | T0交易禁止分账 | 乐刷底层 | T0交易禁止分账 | T0交易禁止分账 | +| -55013 | 订单不支持退款 | 乐刷底层 | 订单不支持退款 | 订单不支持退款 | +| -55014 | 数据异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55015 | 获取订单号失败 | 乐刷底层 | 获取订单号失败 | 检查订单号是否存在 | +| -55016 | json解析异常 | 乐刷底层 | 参数格式异常 | 检查参数格式 | +| -55017 | 更新分账信息失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55018 | 创建分账信息失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55019 | 订单未分账 | 乐刷底层 | 订单未分账 | 检查订单分账状态 | +| -55020 | 查询分账信息失败 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55021 | 该订单已发起分账,请勿重复发起 | 乐刷底层 | 该订单已发起分账,请勿重复发起 | 勿重复发起 | +| -55022 | 分账异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55023 | 手续费承担方异常 | 乐刷底层 | 手续费承担方异常 | 检查手续费承担方参数 | +| -55024 | 该订单状态下不允许分账,请检查后重试 | 乐刷底层 | 该订单状态下不允许分账, 请检查后重试 | 检查订单状态 | +| -55025 | 分账金额有误,请检查后重试 | 乐刷底层 | 分账金额有误,请检查后重试 | 分账金额大于订单金额,或分账金额小于0等非法参数 | +| -55026 | 分账方异常 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55027 | 主体未配置分账比例 | 乐刷底层 | 主体未配置分账比例 | 检查分账主体的分账比例 | +| -55028 | 代理商或商户未开通分账权限 | 乐刷底层 | 代理商或商户未开通分账权限 | 联系乐刷运营 | +| -55029 | t0交易不允许分账 | 乐刷底层 | t0交易不允许分账 | t0交易不允许分账 | +| -55030 | 分账退款查询标记错误 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55031 | 分账次数异常 | 乐刷底层 | 分账次数异常 | 检查分账次数 | +| -55032 | 未分账金额不足以退款 | 乐刷底层 | 未分账金额不足以退款 | 检查退款金额 | +| -55033 | 接口不支持该功能 | 乐刷底层 | 接口不支持该功能 | 联系乐刷运营 | +| -55034 | 撤销失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55035 | 未查询到该分账订单,请检查后重试 | 乐刷底层 | 分账订单不存在 | 检查分账订单号 | +| -55036 | 数据库事务处理失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55037 | 分账订单处理中 | 乐刷底层 | 分账订单处理中 | 稍后重试 | +| -55038 | 分账订单号重复 | 乐刷底层 | 分账订单号重复 | 检查分账订单号后重试 | +| -55039 | 单次分账请求可分账金额必须等于分账金额 | 乐刷底层 | 单次分账请求可分账金额必须等于分账金额 | 检查分账金额 | +| -55040 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 乐刷底层 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 检查分账金额 | +| -55041 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 乐刷底层 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 检查分账商户信息 | +| -55042 | 可分账金额不足或订单已分账 | 乐刷底层 | 可分账金额不足或订单已分账 | 检查分账状态和已分账金额 | +| -55043 | 分账订单更新完成状态失败 | 乐刷底层 | 分账订单更新完成状态失败 | 重试失败后联系乐刷运营 | +| -55044 | 手续费承担次数异常 | 乐刷底层 | 手续费承担次数异常 | 检查商户是否承担了多次手续费 | +| -55045 | 分账查询历史库限流 | 乐刷底层 | 内部异常 | 稍后重试 | +| -55046 | 分账状态异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55047 | 生成结算侧分账服务请求异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55048 | 请求结算侧分账服务结果未知 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55049 | 结算侧分账服务异常返回失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55050 | 参数不合法 | 乐刷底层 | 参数不合法 | 检查参数中的必填、类型(特别是数字字符类型不能混用)等 | +| -55051 | 同一笔订单存在分账中,不允许继续分账 | 乐刷底层 | 同一笔订单存在分账中,不允许继续分账 | 有分账正在进行,请稍后重试 | +| -55052 | 不支持该分账退款模式 | 乐刷底层 | 不支持该分账退款模式 | 不支持该分账退款模式 | + + +# 6. 异步通知触发 +无 + +# 7.Demo示例 +```python +import requests +import hashlib +import base64 +import datetime +import json +##定义签名方法 +def sign_md5_base64(data,key): + signStr = "lepos" + key + data + m2 = hashlib.md5() + m2.update(signStr.encode('utf-8')) + md5str = m2.hexdigest() + base64Str = base64.b64encode(md5str.encode('utf-8')).decode() + return base64Str + +##定义流水号生成方法 +def reqSerialNo(): + now = datetime.datetime.now() + order_id = now.strftime("%Y-%m-%d-%H-%M-%S").replace("-", "") + return order_id + +##服务商参数 +agentId="" ## 这里填写服务商编号 +key="" ## 这里填写服务商密钥,与服务商进件和交易密钥一致 +request_url="https://saas-mch.leshuazf.com/api/share-merchant/createSumAccredit" ## 这里填写请求的接口地址 + +header={'Content-Type': 'application/x-www-form-urlencoded'} +##业务参数 +data={ + "cmd": "balance_allot_create", + "merchant_id": "6763019848", + "third_id": reqSerialNo(), + "amount": "1", + "remark": "分账备注", + "details": json.dumps([{"user_id":"6763019848","user_type":"2","amount":"1"}]) ##json.dumps() 是把python对象转换成json对象的一个过程,生成的是字符串 +} + +body = { + "agentId":agentId, + "version": "1.0", + "reqSerialNo": reqSerialNo(), + "sign": sign_md5_base64(json.dumps(data), key), + "data": json.dumps(data) +} + +req = requests.post(url=request_url, data=body,headers=header) + + +if req.status_code != 200: + print("Http请求失败,状态码:" + str(req.status_code) + ",错误信息:" + req.text) +else: + print(req.json()) + +``` diff --git a/docs/商家余额分账/2. 余额分账结果查询.md b/docs/商家余额分账/2. 余额分账结果查询.md new file mode 100644 index 0000000..6185e80 --- /dev/null +++ b/docs/商家余额分账/2. 余额分账结果查询.md @@ -0,0 +1,224 @@ +# 1. 接口用途 +该接口可查询发起的余额分账的结果。 + +# 2. 接口说明 +## (1)适用对象 + 1. 一级代理商 + +## (2)接口地址 +测试环境地址:无 + +生产环境地址:https://saas-mch.leshuazf.com/api/share-merchant/querySumAccredit + +## (3)测试参数 +无 + +## (4)特别说明 +## (5)频率限制 +40次/20秒 + +# 3. 接口请求 +## (1)请求方式 +1. 接口以 https的POST方式进行调用。 +2. 请求头部的 content-type 类型:application/x-www-form-urlencoded + +## (2)请求参数 +### 公共请求参数 +| **参数名称** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| **agentId** | String | M | 服务商编号,由乐刷分配的接入方唯一标识,明文传输。 | "2888568" | +| **version** | String | M | 目前固定值1.0 | "1.0" | +| **reqSerialNo** | String | M | 请求流水号(yyyyMMddHHmmssSSSXXXXX,其中 XXXXX为5位顺序号,禁止使用UUID等无意义数据) | "202207142012591234" | +| **sign** | String | M | 签名串,业务数据明文参与签名后经 Base64 编码的字符串。
签名方法详见[乐刷聚合签名](https://www.yuque.com/docs/share/a57e3853-70e6-445d-9193-e1892594b0ea?# 《聚合业务接口常规签名》) | "ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE" | +| **data** | String | M | 业务数据明文字符串(Json格式字符串,参照各个接口中输入参数说明) | '{"cmd": "balance_allot_query", "merchant_id": "6763019848", "third_id": "202207151719300aNoeMhd"}' | + + +### 业务请求参数 +| **参数** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | --- | --- | --- | +| cmd | String | M | 服务名称,本处固定为:balance_allot_query | "balance_allot_query" | +| merchant_id | String | M | 分账方的乐刷商户编号 | | +| third_id | String | M | 商户分账单号 | | + + +## (3)请求示例 +```json +{'agentId': '2653276', 'version': '1.0', 'reqSerialNo': '20220805151947', 'sign': 'MzBlNGUxNGFiMzg5NWYwZTVlNzcyNTQwNzIwNWQwMjc=', 'data': '{"cmd": "balance_allot_query", "merchant_id": "6763019848", "third_id": "202207151719300aNoeMhd"}'} + +``` + +```plain +curl --location --request POST 'http://t-saas-mch.lepass.cn/api/share-merchant/querySumAccredit' \ +--form 'agentId=2888568' \ +--form 'version=1.0' \ +--form 'reqSerialNo=20220715110526' \ +--form 'sign=ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE=' \ +--form 'data={"cmd":"balance_allot_query","third_id":"5856212145754114","merchant_id":"548957978"}' +``` + +```plain +https://saas-mch.leshuazf.com/api/share-merchant/querySumAccredit?agentId=2653276&version=1.0&reqSerialNo=20220805151947&sign=MzBlNGUxNGFiMzg5NWYwZTVlNzcyNTQwNzIwNWQwMjc%3D&data=%7B%22cmd%22%3A+%22balance_allot_query%22%2C+%22merchant_id%22%3A+%226763019848%22%2C+%22third_id%22%3A+%22202207151719300aNoeMhd%22%7D +``` + +# 4. 接口响应 +## (1)响应参数 +| **参数** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| respCode | String | M | 网关应答码。
000000,代表网关转发成功,具体的成功需要看retcode和status
其他应答码代表网关转发失败 | "000000" | +| respMsg | String | M | 网关应答说明,成功时返回“成功” | "成功" | +| version | String | M | 版本号 | "1.0" | +| reqSerialNo | String | M | 请求流水号 | "20220715122334" | +| data | Object | O | 分账详情,JSON格式
respCode=000000时返回 | | +| ┖retcode | String | M | 业务应答码,
0表示业务受理成功,分账成功需要看status
其他表示业务受理失败 | '0' | +| ┖retmsg | String | M | 业务应答说明
业务受理成功时返回“success” | 'success' | +| ┖merchant_id | String | O | 分账方的乐刷商户号
retcode=0时返回 | '8613319257' | +| ┖third_id | String | O | 商户分账单号
retcode=0时返回 | '20220715173529LDxUoE9l' | +| ┖leshua_id | String | O | 乐刷分账单号
retcode=0时返回 | "2022071563329181703" | +| ┖amount | String | O | 分账总金额,单位为分
retcode=0时返回 | "2" | +| ┖remark | String | O | 分账备注
retcode=0时返回 | '分账备注' | +| ┖status | String | O | 分账状态,枚举如下:
0:处理中
1:分账成功
2:分账失败
3:分账撤销中
4:分账撤销成功
5:分账撤销失败
说明:分账成功后,资金次日统一出款
retcode=0时返回 | '1' | +| ┖details | String[] | O | 分账详情,list格式,返回请求时送的details.
retcode=0时返回
列表内字段说明:
1. user_id:分账接收方/分账方的乐刷商户编号
2. user_type:分账接收方类型,此处
3. amount:分账金额,单位为分 | '[{"user_id":"8613319257","user_type":"2","amount":"1"},{"user_id":"8123016461","user_type":"2","amount":"1"}]' | + + +## (2)响应示例(正常) +```json +{'version': '1.0', 'respCode': '000000', 'respMsg': '成功', 'reqSerialNo': '20220715182702', 'data': {'third_id': '20220715173529LDxUoE9l', 'amount': '2', 'leshua_id': '2022071563329181703', 'details': '[{"amount":"1","user_id":"8123016461","user_type":"2"},{"amount":"1","user_id":"8613319257","user_type":"2"}]', 'remark': '分账备注', 'merchant_id': '8613319257', 'retmsg': 'success', 'retcode': '0', 'status': '1'}} +``` + +## (3)响应示例(异常) +```json +{'version': '1.0', 'respCode': '000103', 'respMsg': '汇总分账失败db_not_found', 'reqSerialNo': '20220715184903'} + +``` + +# 5. 错误码 +**respCode 错误码** + +| **错误码** | **错误描述** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| 000001 | 必填参数为空 | 乐刷聚合 | 标注为“M”的参数值为空 | 提供为“M”的参数值 | +| 000002 | 签名验证失败 | 乐刷聚合 | 请求方提供的sign参数值为错误的签名 | 1. 请确认服务商编号正确且已生成KEY 2 请求方参照签名算法,修正sign参数值 | +| 000017 | 渠道商未开通此业务 | 乐刷聚合 | 当前服务商为开通该业务 服务商不属于一级服务商,暂未开通该业务 | 联系运营人员 | +| 000026 | 已暂停代理商进件 | 乐刷聚合 | 当前服务商进件商户权限已被关闭 | 联系运营人员 | +| 000027 | 商户已禁用分账功能 | 乐刷聚合 | 该商户分账权限已被关闭 | 联系运营人员 | +| 000004 | 接口调用频率限制 | 乐刷聚合 | 当前服务商调用接口过于频繁 | 降低接口调用频率 | +| 000005 | json格式或者数据类型不正确 | 乐刷聚合 | data 不是一个合法的json字符串或者字段类型不匹配 | 参照文档修正data值 | +| 000051 | 商户号不正确 | 乐刷聚合 | 商户号在乐刷不存在或者不属于该服务商 | 检查修正商户号 | +| 000999 | 系统异常 | 乐刷聚合 | 系统升级或出现未知异常 | 紧急请联系乐刷运营 | +| 000063 | 商户未开通t1结算状态 | 乐刷聚合 | 分账接受放未开通T1结算 | 检查T1结算状态 | + + +**respCode =000000时,业务错误码retcode如下:** + +| **错误码(retcode)** | **错误描述(****retmsg****)** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| -55001 | 分账请求中转失败 | 乐刷底层 | 可能调用上游失败 | 重试失败后联系乐刷运营 | +| -55002 | 不存在商户分账号 | 乐刷底层 | 商户号不存在或为空 | 检查商户号 | +| -55003 | 商户分账号重复 | 乐刷底层 | 多个分账商户号中有重复的商户号 | 检查商户号 | +| -55004 | 创建乐刷分账号失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55005 | 禁止分账 | 乐刷底层 | 商户被禁止分账 | 联系乐刷运营 | +| -55006 | 可分账余额小于等于零 | 乐刷底层 | 分账余额不足 | 检查是否已分账过,或重复分账, 或分账金额计算是否有误 | +| -55007 | 分账比例异常 | 乐刷底层 | 合同配置的分账比例非法 | 联系乐刷运营 | +| -55008 | 订单已分账或商户分账号重复 | 乐刷底层 | 订单已分账或商户分账号重复 | 检查分账订单是否已分账 | +| -55009 | 分账方数量限制 | 乐刷底层 | 分账方数量限制 | 检查分账方数量,不能超过50 | +| -55010 | 未查询到该订单,请检查后重试 | 乐刷底层 | 订单不存在 | 检查订单号 | +| -55011 | 不是分账订单 | 乐刷底层 | 订单不是分账订单,不能分账 | 检查订单号对应的订单类型 | +| -55012 | T0交易禁止分账 | 乐刷底层 | T0交易禁止分账 | T0交易禁止分账 | +| -55013 | 订单不支持退款 | 乐刷底层 | 订单不支持退款 | 订单不支持退款 | +| -55014 | 数据异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55015 | 获取订单号失败 | 乐刷底层 | 获取订单号失败 | 检查订单号是否存在 | +| -55016 | json解析异常 | 乐刷底层 | 参数格式异常 | 检查参数格式 | +| -55017 | 更新分账信息失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55018 | 创建分账信息失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55019 | 订单未分账 | 乐刷底层 | 订单未分账 | 检查订单分账状态 | +| -55020 | 查询分账信息失败 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55021 | 该订单已发起分账,请勿重复发起 | 乐刷底层 | 该订单已发起分账,请勿重复发起 | 勿重复发起 | +| -55022 | 分账异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55023 | 手续费承担方异常 | 乐刷底层 | 手续费承担方异常 | 检查手续费承担方参数 | +| -55024 | 该订单状态下不允许分账,请检查后重试 | 乐刷底层 | 该订单状态下不允许分账, 请检查后重试 | 检查订单状态 | +| -55025 | 分账金额有误,请检查后重试 | 乐刷底层 | 分账金额有误,请检查后重试 | 分账金额大于订单金额,或分账金额小于0等非法参数 | +| -55026 | 分账方异常 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55027 | 主体未配置分账比例 | 乐刷底层 | 主体未配置分账比例 | 检查分账主体的分账比例 | +| -55028 | 代理商或商户未开通分账权限 | 乐刷底层 | 代理商或商户未开通分账权限 | 联系乐刷运营 | +| -55029 | t0交易不允许分账 | 乐刷底层 | t0交易不允许分账 | t0交易不允许分账 | +| -55030 | 分账退款查询标记错误 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55031 | 分账次数异常 | 乐刷底层 | 分账次数异常 | 检查分账次数 | +| -55032 | 未分账金额不足以退款 | 乐刷底层 | 未分账金额不足以退款 | 检查退款金额 | +| -55033 | 接口不支持该功能 | 乐刷底层 | 接口不支持该功能 | 联系乐刷运营 | +| -55034 | 撤销失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55035 | 未查询到该分账订单,请检查后重试 | 乐刷底层 | 分账订单不存在 | 检查分账订单号 | +| -55036 | 数据库事务处理失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55037 | 分账订单处理中 | 乐刷底层 | 分账订单处理中 | 稍后重试 | +| -55038 | 分账订单号重复 | 乐刷底层 | 分账订单号重复 | 检查分账订单号后重试 | +| -55039 | 单次分账请求可分账金额必须等于分账金额 | 乐刷底层 | 单次分账请求可分账金额必须等于分账金额 | 检查分账金额 | +| -55040 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 乐刷底层 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 检查分账金额 | +| -55041 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 乐刷底层 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 检查分账商户信息 | +| -55042 | 可分账金额不足或订单已分账 | 乐刷底层 | 可分账金额不足或订单已分账 | 检查分账状态和已分账金额 | +| -55043 | 分账订单更新完成状态失败 | 乐刷底层 | 分账订单更新完成状态失败 | 重试失败后联系乐刷运营 | +| -55044 | 手续费承担次数异常 | 乐刷底层 | 手续费承担次数异常 | 检查商户是否承担了多次手续费 | +| -55045 | 分账查询历史库限流 | 乐刷底层 | 内部异常 | 稍后重试 | +| -55046 | 分账状态异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55047 | 生成结算侧分账服务请求异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55048 | 请求结算侧分账服务结果未知 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55049 | 结算侧分账服务异常返回失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55050 | 参数不合法 | 乐刷底层 | 参数不合法 | 检查参数中的必填、类型(特别是数字字符类型不能混用)等 | +| -55051 | 同一笔订单存在分账中,不允许继续分账 | 乐刷底层 | 同一笔订单存在分账中,不允许继续分账 | 有分账正在进行,请稍后重试 | +| -55052 | 不支持该分账退款模式 | 乐刷底层 | 不支持该分账退款模式 | 不支持该分账退款模式 | + + +# 6. 异步通知触发 +无 + +# 7.Demo示例 +```python +import requests +import hashlib +import base64 +import datetime +import json +##定义签名方法 +def sign_md5_base64(data,key): + signStr = "lepos" + key + data + m2 = hashlib.md5() + m2.update(signStr.encode('utf-8')) + md5str = m2.hexdigest() + base64Str = base64.b64encode(md5str.encode('utf-8')).decode() + return base64Str + +##定义流水号生成方法 +def reqSerialNo(): + now = datetime.datetime.now() + order_id = now.strftime("%Y-%m-%d-%H-%M-%S").replace("-", "") + return order_id + +##服务商参数 +agentId="" ## 这里填写服务商编号 +key="" ## 这里填写服务商密钥,与服务商进件和交易密钥一致 +request_url="https://saas-mch.leshuazf.com/api/share-merchant/querySumAccredit" ## 这里填写请求的接口地址 + +header={'Content-Type': 'application/x-www-form-urlencoded'} +##业务参数 +data={ + "cmd": "balance_allot_query", + "merchant_id": "6763019848", + "third_id": "202207151719300aNoeMhd"} + +body = { + "agentId":agentId, + "version": "1.0", + "reqSerialNo": reqSerialNo(), + "sign": sign_md5_base64(json.dumps(data), key), + "data": json.dumps(data) +} + +req = requests.post(url=request_url, data=body,headers=header) + + +if req.status_code != 200: + print("Http请求失败,状态码:" + str(req.status_code) + ",错误信息:" + req.text) +else: + print(req.text) + +``` + + diff --git a/docs/商家余额分账/3.余额分账撤销.md b/docs/商家余额分账/3.余额分账撤销.md new file mode 100644 index 0000000..73a76a8 --- /dev/null +++ b/docs/商家余额分账/3.余额分账撤销.md @@ -0,0 +1,224 @@ +# 1. 接口用途 +该接口可撤销已发起的余额分账。 + +# 2. 接口说明 +## (1) 适用对象 + 1. 一级代理商 + +## (2)接口地址 +测试环境地址:无 + +生产环境地址:https://saas-mch.leshuazf.com/api/share-merchant/canceSumAccredit + +## (3)测试参数 +无 + +## (4)特别说明 +1. 分账单号撤销后,不可再继续撤销,可通过查询接口查询该笔订单的当前状态。 +2. 跨日后,由于已经步入结算环节,不支持再进行撤销。 + +## (5)频率限制 +40次/20秒 + +# 3. 接口请求 +## (1)请求方式 +1. 接口以 https的POST方式进行调用。 +2. 请求头部的 content-type 类型:application/x-www-form-urlencoded + +## (2)请求参数 +### 公共请求参数 +| **参数名称** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| **agentId** | String | M | 服务商编号,由乐刷分配的接入方唯一标识,明文传输。 | "2888568" | +| **version** | String | M | 目前固定值1.0 | "1.0" | +| **reqSerialNo** | String | M | 请求流水号(yyyyMMddHHmmssSSSXXXXX,其中 XXXXX为5位顺序号,禁止使用UUID等无意义数据) | "202207142012591234" | +| **sign** | String | M | 签名串,业务数据明文参与签名后经 Base64 编码的字符串。
签名方法详见[乐刷聚合签名](https://www.yuque.com/docs/share/a57e3853-70e6-445d-9193-e1892594b0ea?# 《聚合业务接口常规签名》) | "ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE" | +| **data** | String | M | 业务数据明文字符串(Json格式字符串,参照各个接口中输入参数说明) | '{"cmd": "balance_allot_cancel", "merchant_id": "6763019848", "third_id": "202207151719300aNoeMhd"}' | + + +### 业务请求参数 +| **参数** | **参数类型** | **必填** | **长度** | **说明** | **示例** | +| --- | --- | --- | --- | --- | --- | +| cmd | String | M | | 服务名称,本处固定为:balance_allot_cancel | "balance_allot_cancel" | +| merchant_id | String | M | | 分账方的乐刷商户编号 | "8613319257" | +| third_id | String | M | | 商户分账单号 | "20220715173529LDxUoE9l" | + + +## (3)请求示例 +```json +{'agentId': '2653276', 'version': '1.0', 'reqSerialNo': '20220805152236', 'sign': 'ZDE2MzU0NjQ5MDE5MTdkYTYzZTExMTYyYzgxZjdlNzM=', 'data': '{"cmd": "balance_allot_cancel", "merchant_id": "6763019848", "third_id": "202207151719300aNoeMhd"}'} +``` + +```plain +curl --location --request POST 'http://t-saas-mch.lepass.cn/api/share-merchant/canceSumAccredit' \ +--form 'agentId=2888568' \ +--form 'version=1.0' \ +--form 'reqSerialNo=20220715110526' \ +--form 'sign=ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE=' \ +--form 'data={"third_id":"5856212145754114","cmd":"balance_allot_cancel","merchant_id":"0000000017"}' +``` + +```plain +https://saas-mch.leshuazf.com/api/share-merchant/canceSumAccredit?agentId=2653276&version=1.0&reqSerialNo=20220805152236&sign=ZDE2MzU0NjQ5MDE5MTdkYTYzZTExMTYyYzgxZjdlNzM%3D&data=%7B%22cmd%22%3A+%22balance_allot_cancel%22%2C+%22merchant_id%22%3A+%226763019848%22%2C+%22third_id%22%3A+%22202207151719300aNoeMhd%22%7D +``` + +# 4. 接口响应 +## (1)响应参数 +| **参数** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| respCode | String | M | 应答码。
000000,代表分账关系解绑成功
其他应答码代表分账关系解绑失败 | "000000" | +| respMsg | String | M | 应答说明,成功时返回“成功” | "成功" | +| version | String | M | 版本号 | "1.0" | +| reqSerialNo | String | M | 请求流水号 | "20220715122334" | +| data | Object | O | 业务参数
respCode=000000时返回 | {'third_id': '20220715173529LDxUoE9l', 'leshua_id': '2022071563329181703', 'merchant_id': '8613319257', 'retmsg': 'success', 'retcode': '0', 'status': '4'}} | +| ┖retcode | String | M | 业务应答码,
0表示业务受理成功,分账成功需要看status
其他表示业务受理失败 | '0' | +| ┖retmsg | String | M | 业务应答说明
业务受理成功时返回“success” | 'success' | +| ┖third_id | String | O | 商户分账单号
retcode=0时返回 | '20220715173529LDxUoE9l' | +| ┖leshua_id | String | O | 乐刷分账单号
retcode=0时返回 | "2022071563329181703" | +| ┖merchant_id | String | O | 分账方的乐刷商户号
retcode=0时返回 | '8613319257' | +| ┖status | String | O | 分账状态,枚举如下:
0:处理中
1:分账成功
2:分账失败
3:分账撤销中
4:分账撤销成功
5:分账撤销失败
说明:分账成功后,资金次日统一出款
retcode=0时返回 | '1' | + + +## (2)响应示例(正常) +```json +{'version': '1.0', 'respCode': '000000', 'respMsg': '成功', 'reqSerialNo': '20220715185901', 'data': {'third_id': '20220715173529LDxUoE9l', 'leshua_id': '2022071563329181703', 'merchant_id': '8613319257', 'retmsg': 'success', 'retcode': '0', 'status': '4'}} + +``` + +## (3)响应示例(异常) +```json +{'version': '1.0', 'respCode': '000103', 'respMsg': '汇总分账失败status_not_allow', 'reqSerialNo': '20220715190421'} +``` + +# 5. 错误码 +**respCode 错误码** + +| **错误码** | **错误描述** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| 000001 | 必填参数为空 | 乐刷 | 标注为“M”的参数值为空 | 提供为“M”的参数值 | +| 000002 | 签名验证失败 | 乐刷 | 请求方提供的sign参数值为错误的签名 | 1. 请确认服务商编号正确且已生成KEY 1. 请求方参照签名算法,修正sign参数值 | +| 000017 | 渠道商未开通此业务 | 乐刷 | 当前服务商为开通该业务 服务商不属于一级服务商,暂未开通该业务 | 联系运营人员 | +| 000026 | 已暂停代理商进件 | 乐刷 | 当前服务商进件商户权限已被关闭 | 联系运营人员 | +| 000027 | 商户已禁用分账功能 | 乐刷 | 该商户分账权限已被关闭 | 联系运营人员 | +| 000004 | 接口调用频率限制 | 乐刷 | 当前服务商调用接口过于频繁 | 降低接口调用频率 | +| 000005 | json格式或者数据类型不正确 | 乐刷 | data 不是一个合法的json字符串或者字段类型不匹配 | 参照文档修正data值 | +| 000051 | 商户号不正确 | 乐刷 | 商户号在乐刷不存在或者不属于该服务商 | 检查修正商户号 | +| 000999 | 系统异常 | 乐刷 | 系统升级或出现未知异常 | 紧急请联系乐刷运营 | +| 000063 | 商户未开通t1结算状态 | 乐刷 | 分账接受放未开通T1结算 | 检查T1结算状态 | + + +**respCode =000000时,业务错误码retcode如下:** + +| **错误码(retcode)** | **错误描述(****retmsg****)** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| -55001 | 分账请求中转失败 | 结算 | 可能调用上游失败 | 重试失败后联系乐刷运营 | +| -55002 | 不存在商户分账号 | 结算 | 商户号不存在或为空 | 检查商户号 | +| -55003 | 商户分账号重复 | 结算 | 多个分账商户号中有重复的商户号 | 检查商户号 | +| -55004 | 创建乐刷分账号失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55005 | 禁止分账 | 结算 | 商户被禁止分账 | 联系乐刷运营 | +| -55006 | 可分账余额小于等于零 | 结算 | 分账余额不足 | 检查是否已分账过,或重复分账, 或分账金额计算是否有误 | +| -55007 | 分账比例异常 | 结算 | 合同配置的分账比例非法 | 联系乐刷运营 | +| -55008 | 订单已分账或商户分账号重复 | 结算 | 订单已分账或商户分账号重复 | 检查分账订单是否已分账 | +| -55009 | 分账方数量限制 | 结算 | 分账方数量限制 | 检查分账方数量,不能超过50 | +| -55010 | 未查询到该订单,请检查后重试 | 结算 | 订单不存在 | 检查订单号 | +| -55011 | 不是分账订单 | 结算 | 订单不是分账订单,不能分账 | 检查订单号对应的订单类型 | +| -55012 | T0交易禁止分账 | 结算 | T0交易禁止分账 | T0交易禁止分账 | +| -55013 | 订单不支持退款 | 结算 | 订单不支持退款 | 订单不支持退款 | +| -55014 | 数据异常 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55015 | 获取订单号失败 | 结算 | 获取订单号失败 | 检查订单号是否存在 | +| -55016 | json解析异常 | 结算 | 参数格式异常 | 检查参数格式 | +| -55017 | 更新分账信息失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55018 | 创建分账信息失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55019 | 订单未分账 | 结算 | 订单未分账 | 检查订单分账状态 | +| -55020 | 查询分账信息失败 | 结算 | 内部异常 | 联系乐刷运营 | +| -55021 | 该订单已发起分账,请勿重复发起 | 结算 | 该订单已发起分账,请勿重复发起 | 勿重复发起 | +| -55022 | 分账异常 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55023 | 手续费承担方异常 | 结算 | 手续费承担方异常 | 检查手续费承担方参数 | +| -55024 | 该订单状态下不允许分账,请检查后重试 | 结算 | 该订单状态下不允许分账, 请检查后重试 | 检查订单状态 | +| -55025 | 分账金额有误,请检查后重试 | 结算 | 分账金额有误,请检查后重试 | 分账金额大于订单金额,或分账金额小于0等非法参数 | +| -55026 | 分账方异常 | 结算 | 内部异常 | 联系乐刷运营 | +| -55027 | 主体未配置分账比例 | 结算 | 主体未配置分账比例 | 检查分账主体的分账比例 | +| -55028 | 代理商或商户未开通分账权限 | 结算 | 代理商或商户未开通分账权限 | 联系乐刷运营 | +| -55029 | t0交易不允许分账 | 结算 | t0交易不允许分账 | t0交易不允许分账 | +| -55030 | 分账退款查询标记错误 | 结算 | 内部异常 | 联系乐刷运营 | +| -55031 | 分账次数异常 | 结算 | 分账次数异常 | 检查分账次数 | +| -55032 | 未分账金额不足以退款 | 结算 | 未分账金额不足以退款 | 检查退款金额 | +| -55033 | 接口不支持该功能 | 结算 | 接口不支持该功能 | 联系乐刷运营 | +| -55034 | 撤销失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55035 | 未查询到该分账订单,请检查后重试 | 结算 | 分账订单不存在 | 检查分账订单号 | +| -55036 | 数据库事务处理失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55037 | 分账订单处理中 | 结算 | 分账订单处理中 | 稍后重试 | +| -55038 | 分账订单号重复 | 结算 | 分账订单号重复 | 检查分账订单号后重试 | +| -55039 | 单次分账请求可分账金额必须等于分账金额 | 结算 | 单次分账请求可分账金额必须等于分账金额 | 检查分账金额 | +| -55040 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 结算 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 检查分账金额 | +| -55041 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 结算 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 检查分账商户信息 | +| -55042 | 可分账金额不足或订单已分账 | 结算 | 可分账金额不足或订单已分账 | 检查分账状态和已分账金额 | +| -55043 | 分账订单更新完成状态失败 | 结算 | 分账订单更新完成状态失败 | 重试失败后联系乐刷运营 | +| -55044 | 手续费承担次数异常 | 结算 | 手续费承担次数异常 | 检查商户是否承担了多次手续费 | +| -55045 | 分账查询历史库限流 | 结算 | 内部异常 | 稍后重试 | +| -55046 | 分账状态异常 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55047 | 生成结算侧分账服务请求异常 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55048 | 请求结算侧分账服务结果未知 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55049 | 结算侧分账服务异常返回失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55050 | 参数不合法 | 结算 | 参数不合法 | 检查参数中的必填、类型(特别是数字字符类型不能混用)等 | +| -55051 | 同一笔订单存在分账中,不允许继续分账 | 结算 | 同一笔订单存在分账中,不允许继续分账 | 有分账正在进行,请稍后重试 | +| -55052 | 不支持该分账退款模式 | 结算 | 不支持该分账退款模式 | 不支持该分账退款模式 | + + + + +# 6. 异步通知触发 +无 + +# 7.Demo示例 +```python +import requests +import hashlib +import base64 +import datetime +import json +##定义签名方法 +def sign_md5_base64(data,key): + signStr = "lepos" + key + data + m2 = hashlib.md5() + m2.update(signStr.encode('utf-8')) + md5str = m2.hexdigest() + base64Str = base64.b64encode(md5str.encode('utf-8')).decode() + return base64Str + +##定义流水号生成方法 +def reqSerialNo(): + now = datetime.datetime.now() + order_id = now.strftime("%Y-%m-%d-%H-%M-%S").replace("-", "") + return order_id + +##服务商参数 +agentId="" ## 这里填写服务商编号 +key="" ## 这里填写服务商密钥,与服务商进件和交易密钥一致 +request_url="https://saas-mch.leshuazf.com/api/share-merchant/canceSumAccredit" ## 这里填写请求的接口地址 + +header={'Content-Type': 'application/x-www-form-urlencoded'} +##业务参数 +data={ + "cmd": "balance_allot_cancel", + "merchant_id": "6763019848", + "third_id": "202207151719300aNoeMhd" +} + +body = { + "agentId":agentId, + "version": "1.0", + "reqSerialNo": reqSerialNo(), + "sign": sign_md5_base64(json.dumps(data), key), + "data": json.dumps(data) +} + +req = requests.post(url=request_url, data=body,headers=header) + + +if req.status_code != 200: + print("Http请求失败,状态码:" + str(req.status_code) + ",错误信息:" + req.text) +else: + print(req.json()) + +``` diff --git a/docs/商家余额分账/产品介绍.md b/docs/商家余额分账/产品介绍.md new file mode 100644 index 0000000..95f3bf4 --- /dev/null +++ b/docs/商家余额分账/产品介绍.md @@ -0,0 +1,163 @@ +# 1. 阅读对象 +服务商(即SaaS 一级代理商) + +# 2. 产品简介 +商家分账产品,是乐刷面向商家提供的商家资金管理产品,用以解决实际收款商家需要将收款资金按照一定的周期、比例、金额等规则分账给收款商家合作方的场景诉求,收款商家的合作方可以是合作伙伴、供应商、服务商、平台方等角色。 + +服务商在完成业务申请后,商家可以直接使用乐刷提供的“商户聚合支付平台”PC页面发起分账,或者 + +由商家的服务商通过调用接口的方式代替商家进行分账。 + +# 3. 名词解释 +| **序号** | **名词** | **解释** | +| :---: | --- | --- | +| 1 | 分账方 | 指商品或者服务的提供方,这里指实际收款商家本身。 | +| 2 | 分账接收方 | 与实际收款商家有合作关系的第三方,比如服务商、软件供应商、供货商、平台方等角色,接收分账资金的商家。 | +| 3 | 服务商 | 为实际收款商家提供各种产品和服务的提供方,这里指乐刷的1级代理商。 | +| 4 | 分账比例 | 指分账方的分账订单交易金额中,最大可分给分账接收方的资金比例。 | +| 5 | 分账关系 | 分账方和分账接收方建立绑定关系,该分账方的分账接收方仅限于建立绑定关系的对象。 | +| 6 | 分账标记 | 分账方发起交易时给交易订单(含二维码支付订单和刷卡支付订单)打上的分账标识,仅打上分账标识的订单方可参与商家分账。 | +| 7 | 待分账账户 | 分账方的分账托管账本,分账方的交易打上分账标记后,交易订单的资金将收到分账方的分账托管账本,后续的分账将从该账本中划拨资金给商户进行出款。 | +| 8 | 订单分账 | 分账模式的一种,服务商替分账方发起分账时,基于某笔的交易订单发起分账的分账模式。 | +| 9 | 余额分账 | 分账模式的一种,服务商替分账方发起分账时,基于分账方待分账账户余额发起分账的分账模式,该分账模式无需强依赖具体的交易订单。
分账方的交易订单打上分账标记后,会实时清分入账至分账方的待分账账户中。 | +| 11 | 自动分账 | 余额分账模式的一种,服务商无需开发,通过商户乐刷后台配置好的分账规则执行分账的分账模式。
手动分账,属于不配置分账规则,商户按需求分账资金给予接受方。 | + + +# 3. 适用场景 +## (1)电商平台场景分润 +某电商平台,消费者完成支付后,订单资金收到分账方的待分账账户内,商户根据与物流服务商、流量推广方的约定,将物流服务费、流量推广费通过分账功能分给物流服务商和流量推广方。 + +## (2)服务商抽佣 +在线下交易中,商家通过扫码枪、码牌、POS机等收款产品收款,消费者完成支付支付后,订单资金收到商家的待分账账户内,在经过商家授权后,线下服务商可通过分账产品收取佣金,比如 POS 推广费、智能收银设备服务费等。 + +## (3)代运营平台抽佣场景 +代运营平台给商家提供商家销售系统、小程序引流、营销发券、会员权益等会员营销服务后,采用基于商家的收款订单抽取一定比例佣金的方式收取服务费 + +## (4)设备服务运维场景 +实际收款商家的收款设备提供方给商家提供收银机器、服务后,收款商家需要按照一定的比例或者金额将维修服务费分账给设备提供方。 + +## (5)分润给合作伙伴 +实际收款商家在与他方合作时,可以用“分账”功能,将交易资金分给合作伙伴。 + +例如: + +1. 商家的供应商给商家提供消费产品供应服务,商家可通过零售交易款将部分资金分账给供应商。 +2. 代理商根据与车场商户之间的约定,将停车费分配给智能停车系统商户等。 + +# 4. 产品流程 +## 4.1 分账业务流程图 +![画板](https://cdn.nlark.com/yuque/0/2024/jpeg/1233785/1704271232345-43016a57-0b91-491c-bfe6-b1c4085182f8.jpeg) + + + +## 4.2 商户分账资金流信息流 + + +## 4.3 商户分账业务全流程 + + +![画板](https://cdn.nlark.com/yuque/0/2022/jpeg/529841/1659337621852-30aaeb15-8973-4c79-bd17-8bb597852f5e.jpeg) + +商户分账业务全流程,具体说明如下: + +### (1)商户入网 +拟参与资金分账的各方,包括分账方(即收款商家)、分账接收方(如供应商、物流方、平台方等收款商家的合作方),均需以商户的身份入网至乐刷,获取乐刷的乐刷商户号,后续的分账业务均基于乐刷商户号进行。 + + + +分账方和分账接收方,均支持对私和对公结算。 + + + +目前乐刷提供两种方式供服务商完成商户入网: + +1) API接口:服务商通过接入乐刷提供的商户入网接口,在自有的产品和系统上增加相应的商户入网资料提交入口,完成分账方和分账接收方的入网。 + +注:分账接收方如无需走交易,则无需进行费率配置等操作。 + +2)代理商平台入网:乐刷为服务商提供服务商业务管理平台,包括服务商PC后台和服务商APP两种,服务商收集到商户的资金后,可登录该平台录入并提交资料完成商户入网 + +### (2 )商户开通分账 +商家要进行资金分账,需要给分账方开通分账的功能,开通分账的功能需要商家签署对应的协议。目前乐刷提供了两种方式供服务商推进商家完成分账功能的开通: + +1)API接口:服务商通过接口乐刷提供的商户分账开通接口,完成商户分账功能的开通。目前乐刷支持通过接口获取电子协议和通过接口上传线下纸质协议的方式,商户完成协议签署后,乐刷进行审核,审核通过即完成分账功能的开通。 + +详细的开通流程见操作指引。 + +2)商户PC开通:服务商不想进行开发的情况下,可以让商家登录乐刷提供的商户PC平台,在乐刷的商户PC平台内完成签署。 + +详细的页面操作流程见操作指引。 + +### (3)商户交易 +商户的分账资金来源于其收款产生的交易,不支持非收单资金进行分账(如外部资金充值等),目前商户可通过以下两种方式完成商户交易: + +1)API接口交易:服务商/商户通过接入乐刷提供的交易接口完成交易。 + +2)乐刷码牌、POS机交易:服务商不开发的情况下,也可以通过使用乐刷提供的码牌、码牌音箱、智能POS、传统POS等收款设备来完成收款。 + +注:目前支持按照订单维度和按照商户维度确定交易是否需要分账,如某部分订单的资金需要作为普通订单不参与分账,则需要通过接口的方式接入乐刷支付并在支付接口中标记哪笔订单是否需要分账。 + +### (4)商户分账 +乐刷的分账为异步分账,即支付成功后方可发起分账。目前乐刷提供多种方式供服务商发起分账,具体如下: + +1)商户PC分账:商户登录乐刷的商户PC平台,在商户PC页面操作和配置分账。目前商户可在商户PC平台选择配置自动分账规则和手动发起分账两种模式,商户可选任一一种发起分账。 + +自动分账:商户配置分账规则,乐刷按照商户配置的分账规则,每日将交易订单的资金分给各分账接收方 + +手动分账:商户在页面上填写分账接收方和分账金额,提交请求发起分账。 + +2)API接口分账:服务商具备自己的业务系统的情况下,可以通过调用乐刷的分账接口向乐刷发起分账请求。根据分账的模式不同,乐刷提供三种方式向乐刷发起分账请求: + ++ 订单分账:请求乐刷发起分账时,需要明确哪笔交易订单需要分账 ++ 余额分账:请求乐刷发起分账时,无需指明具体的订单,只要商户的待分账账户资金足够,并且分账给他人的资金在规定比例内,即可发起分账。 ++ 自动分账:通过接口将商户分账的规则提交给乐刷,乐刷按照提交的规则每日自动给商户发起分账。 + +### (5)资金出款 +商户发起分账请求且请求成功后,乐刷默认D1日将分账资金打款至各分账接收方的银行卡。举例:1号发起的分账,2号早上打款。 + +若商家使用乐刷的“商户聚合支付平台”发起支付,则可以选择是否分账后立即到账。注:如使用分账立即到账功能,后续会收取一定的服务费。 + +若商家使用乐刷提供的接口发起分账,暂时不支持分账后立即到账。 + + + +# 5. 产品特性 +**(1)多种落地实现方式,满足不同程度开发能力的服务商所需** + +乐刷提供分账API和商户分账后台,服务商可根据自身的开发能力,灵活选择不同的业务落地模式。 + + + +**(2)多种分账方式,满足各类业务场景的分账诉求** + +针对不同的业务分账场景,乐刷提供了多样的分账模式,可以基于交易订单发起分账,也可以基于商户余额发起分账,满足不同业务场景的复杂分账需求。 + +**** + +**(3)资金安全可控** + +基于收单资金分账,真实资金留存在乐刷备付金账户,商户的分账资金记名在乐刷的托管账本,分账后方可出金,资金安全得到保证,避免资金混用和乱用。 + + + +**(4)支持多种类型的客户参与分账** + +乐刷的分账产品,支持向个人、供应商、平台方、服务商等多方发起分账,并且可根据情况结算至对公账户和对私账户。 + + + +**(5)分账资金控制合理** + +商户进行分账,需要签署协议并约定分账比例和金额,在约定的资金范围内发起分账,避免商户的交易资金被不合理的挪用。 + + + +**(6)****可基于余额或每笔订单进行分账,可单次或多次分账,每次分给多方** + +支持按余额传入具体的分账接收方和分账金额进行分账,每日无次数限制**,**每次分账最多可以分给**50个接收方**。 + +也支持按订单传入具体的分账接收方和分账金额进行分账,一笔订单最多可以分**20次,**每次分账最多可以分给**50个接收方**。 + +# +# + diff --git a/frontend/src/features/admin/components/PublishOptionsDialog.vue b/frontend/src/features/admin/components/PublishOptionsDialog.vue index 87c1a03..ae08aca 100644 --- a/frontend/src/features/admin/components/PublishOptionsDialog.vue +++ b/frontend/src/features/admin/components/PublishOptionsDialog.vue @@ -294,6 +294,13 @@ async function handleSave() {
发布规则
+ + + ): ListingPublishOptions { return { + next_season_pass_enabled: options?.next_season_pass_enabled === true, server_options: normalizeStringList(options?.server_options), face_options: normalizeStringList(options?.face_options), rank_options: normalizeStringList(options?.rank_options), diff --git a/frontend/src/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts index 788319a..6393d1f 100644 --- a/frontend/src/features/seller/composables/usePublishForm.ts +++ b/frontend/src/features/seller/composables/usePublishForm.ts @@ -79,6 +79,7 @@ export function usePublishForm(options: UsePublishFormOptions) { const submitButtonText = computed(() => (isEditMode.value ? '提交修改' : '立即发布')) const submitLoadingText = computed(() => (isEditMode.value ? '提交中...' : '发布中...')) const showBanRecordRiskHint = false + const nextSeasonPassEnabled = computed(() => publishOptions.value.next_season_pass_enabled) const pricing = usePricingCalculator({ form, @@ -724,7 +725,9 @@ export function usePublishForm(options: UsePublishFormOptions) { if (pricing.banRecordOptions.value.length && !form.ban_record) return '请选择封禁记录' if (!form.can_change_game_name) return '请选择是否可以修改游戏名' if (!form.all_hero) return '请选择是否有全英雄' - if (!form.next_season_pass) return '请选择下赛季是否购买通行证' + if (nextSeasonPassEnabled.value && !form.next_season_pass) { + return '请选择下赛季是否购买通行证' + } if (form.depositAmountYuan === '' || Number(form.depositAmountYuan) < 0) return '请填写押金' if (!Number.isFinite(Number(form.depositAmountYuan))) return '押金格式不正确' if ( @@ -919,6 +922,7 @@ export function usePublishForm(options: UsePublishFormOptions) { passwordAndDeviceConfirmed, canPublishAfterAgreements, showBanRecordRiskHint, + nextSeasonPassEnabled, isScanOnlyServer, effectiveLoginMethodOptions, fileInput, diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue index 4af2a98..63aac55 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue @@ -27,6 +27,7 @@ const { passwordAndDeviceConfirmed, canPublishAfterAgreements, showBanRecordRiskHint, + nextSeasonPassEnabled, fileInput, activeUploadKey, form, @@ -441,7 +442,12 @@ const screenshotGuides: Record = { - +