From 03a1af7f2a29d0d8ee9152cfe49eeacdd9804435 Mon Sep 17 00:00:00 2001 From: atoncooper Date: Wed, 4 Nov 2026 17:49:41 +0800 Subject: [PATCH] =?UTF-8?q?[admin]=20feat:=20=E6=94=AF=E4=BB=98=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=B9=B6=E5=85=A5=E7=BB=9F=E4=B8=80=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=B9=B6=E5=8A=A0=E5=9B=BA=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 退役 app-pay-admin:订单/会员/权益事件/SKU/回调、补偿开通、退款复核迁入 app-admin + frontend-admin - 统一 admin_user/admin_session/admin_audit、控制台账户管理与支付域第二 GORM 连接 - 登录安全:设备指纹绑定、会话 IP 绑定(exact/subnet)、可信代理真实 IP、IP+账户双限流、登录/新设备/新 IP/会话异常审计 - CORS 仅精确 Origin、放行 X-Device-Id、CORP/COOP 安全头;部署、CI、文档同步退役 app-pay-admin --- .env.example | 51 +- .github/workflows/ci-pay-admin.yml | 38 - .github/workflows/docker-build.yml | 4 - CHANGELOG.md | 7 + app-go/app-admin/Dockerfile | 23 +- app-go/app-admin/README.md | 71 +- app-go/app-admin/default.yaml | 54 +- app-go/app-admin/go.mod | 9 + app-go/app-admin/go.sum | 21 + app-go/app-admin/internal/config/config.go | 194 ++- .../app-admin/internal/config/config_test.go | 178 +++ .../internal/config/default_yaml_test.go | 57 + app-go/app-admin/internal/db/db.go | 157 ++- app-go/app-admin/internal/db/db_test.go | 96 +- app-go/app-admin/internal/dto/console_user.go | 29 + app-go/app-admin/internal/dto/dto.go | 2 +- app-go/app-admin/internal/dto/pay.go | 165 +++ app-go/app-admin/internal/model/model.go | 22 +- .../app-admin/internal/model/pay.go | 44 +- .../app-admin/internal/payops}/client.go | 57 +- .../app-admin/internal/payops/client_test.go | 116 ++ app-go/app-admin/internal/repo/admin_user.go | 83 ++ app-go/app-admin/internal/repo/audit.go | 19 + app-go/app-admin/internal/repo/moderation.go | 2 +- .../app-admin/internal/repo/pay_membership.go | 16 +- .../app-admin/internal/repo/pay_order.go | 21 +- .../app-admin/internal/repo/pay_product.go | 14 +- .../app-admin/internal/repo/pay_refund.go | 8 +- app-go/app-admin/internal/repo/session.go | 7 +- .../app-admin/internal/repo/shares_query.go | 2 +- app-go/app-admin/internal/router/auth.go | 134 +- .../internal/router/console_users.go | 135 ++ .../internal/router/console_users_test.go | 198 +++ app-go/app-admin/internal/router/device.go | 76 ++ app-go/app-admin/internal/router/pages.go | 2 +- app-go/app-admin/internal/router/pay.go | 478 ++++++++ app-go/app-admin/internal/router/pay_test.go | 1088 +++++++++++++++++ app-go/app-admin/internal/router/router.go | 183 ++- .../app-admin/internal/router/router_test.go | 61 +- app-go/app-admin/internal/router/security.go | 176 ++- .../internal/router/security_test.go | 344 ++++++ app-go/app-admin/internal/service/auth.go | 273 ++++- .../app-admin/internal/service/auth_test.go | 77 +- app-go/app-admin/internal/service/common.go | 14 +- .../internal/service/console_user.go | 183 +++ .../internal/service/console_user_test.go | 144 +++ .../app-admin/internal/service/pay_audit.go | 52 +- .../internal/service/pay_audit_test.go | 93 ++ .../app-admin/internal/service/pay_grant.go | 117 +- .../app-admin/internal/service/pay_query.go | 278 +++++ app-go/app-admin/main.go | 89 +- app-pay-admin/.gitignore | 4 - app-pay-admin/Dockerfile | 42 - app-pay-admin/README.md | 104 -- app-pay-admin/config.dev.yaml | 28 - app-pay-admin/config.docker.yaml | 19 - app-pay-admin/default.yaml | 68 -- app-pay-admin/go.mod | 57 - app-pay-admin/go.sum | 138 --- app-pay-admin/internal/appops/actions.go | 13 - app-pay-admin/internal/certgen/certgen.go | 255 ---- .../internal/certgen/certgen_test.go | 113 -- app-pay-admin/internal/config/config.go | 238 ---- app-pay-admin/internal/config/config_test.go | 155 --- app-pay-admin/internal/db/db.go | 96 -- app-pay-admin/internal/db/db_test.go | 53 - app-pay-admin/internal/logger/logger.go | 183 --- app-pay-admin/internal/repo/user.go | 149 --- app-pay-admin/internal/router/api_test.go | 421 ------- app-pay-admin/internal/router/console.go | 215 ---- app-pay-admin/internal/router/grant.go | 81 -- app-pay-admin/internal/router/memberships.go | 124 -- app-pay-admin/internal/router/orders.go | 153 --- app-pay-admin/internal/router/pages.go | 214 ---- app-pay-admin/internal/router/pages_events.go | 54 - app-pay-admin/internal/router/pages_grants.go | 93 -- .../internal/router/pages_memberships.go | 144 --- app-pay-admin/internal/router/pages_orders.go | 255 ---- .../internal/router/pages_products.go | 56 - app-pay-admin/internal/router/pages_test.go | 248 ---- app-pay-admin/internal/router/pages_users.go | 161 --- app-pay-admin/internal/router/products.go | 39 - app-pay-admin/internal/router/refunds.go | 164 --- app-pay-admin/internal/router/refunds_test.go | 137 --- app-pay-admin/internal/router/router.go | 112 -- app-pay-admin/internal/router/users.go | 117 -- app-pay-admin/internal/router/webui.go | 387 ------ app-pay-admin/internal/router/webui_test.go | 341 ------ app-pay-admin/main.go | 183 --- app-pay-admin/scripts/gen-dev-cert.ps1 | 57 - app-pay-admin/scripts/gen-dev-cert.sh | 48 - app-pay-admin/web/assets/app.css | 306 ----- app-pay-admin/web/assets/login-art.svg | 56 - app-pay-admin/web/assets/logo.svg | 12 - app-pay-admin/web/templates/base.tmpl | 48 - app-pay-admin/web/templates/events.tmpl | 37 - app-pay-admin/web/templates/grants.tmpl | 58 - app-pay-admin/web/templates/login.tmpl | 47 - .../web/templates/member_events.tmpl | 28 - app-pay-admin/web/templates/memberships.tmpl | 48 - app-pay-admin/web/templates/order_detail.tmpl | 42 - app-pay-admin/web/templates/orders.tmpl | 49 - app-pay-admin/web/templates/products.tmpl | 23 - app-pay-admin/web/templates/refunds.tmpl | 44 - app-pay-admin/web/templates/users.tmpl | 58 - app-pay-admin/web/web.go | 18 - deploy/docker/41-app-task.yml | 2 +- deploy/docker/95-app-pay-admin.yml | 34 - deploy/docker/96-app-admin.yml | 16 +- deploy/docker/README.md | 2 +- deploy/docker/start-backend.sh | 4 +- deploy/k8s/30-apps/app-admin.yaml | 22 +- deploy/k8s/30-apps/app-pay-admin.yaml | 86 -- deploy/k8s/30-apps/app-task.yaml | 2 +- deploy/k8s/README.md | 7 +- deploy/k8s/kustomization.yaml | 5 +- deploy/swarm/docker-stack.yml | 86 +- docker-compose.client.yml | 34 +- docker-compose.yml | 79 +- docs/README.md | 3 +- docs/api.md | 6 +- docs/app-admin.md | 242 +++- docs/app-pay-admin.md | 86 -- docs/app-pay.md | 24 +- docs/architecture.md | 12 +- docs/configuration.md | 5 +- docs/deployment.md | 13 +- docs/faq.md | 4 + docs/features.md | 4 +- docs/getting-started.md | 13 +- docs/project-structure.md | 5 +- docs/setup-guide.md | 9 +- docs/vscode-setup.md | 2 +- frontend-admin/README.md | 28 +- frontend-admin/src/App.tsx | 22 +- frontend-admin/src/api/accounts.ts | 47 + frontend-admin/src/api/client.ts | 15 +- frontend-admin/src/api/pay.ts | 231 ++++ frontend-admin/src/api/sandbox.ts | 4 +- frontend-admin/src/api/types.ts | 2 +- frontend-admin/src/components/AdminLayout.tsx | 79 +- .../src/components/ChangePasswordModal.tsx | 2 +- frontend-admin/src/components/format.ts | 9 + frontend-admin/src/components/pay-meta.ts | 56 + frontend-admin/src/device.ts | 43 + frontend-admin/src/pages/Accounts.tsx | 304 +++++ frontend-admin/src/pages/Audit.tsx | 117 +- frontend-admin/src/pages/Login.tsx | 7 +- frontend-admin/src/pages/pay/Events.tsx | 176 +++ frontend-admin/src/pages/pay/Grants.tsx | 275 +++++ frontend-admin/src/pages/pay/MemberEvents.tsx | 136 +++ frontend-admin/src/pages/pay/Memberships.tsx | 283 +++++ frontend-admin/src/pages/pay/OrderDetail.tsx | 256 ++++ frontend-admin/src/pages/pay/Orders.tsx | 250 ++++ frontend-admin/src/pages/pay/Products.tsx | 105 ++ frontend-admin/src/pages/pay/Refunds.tsx | 289 +++++ 156 files changed, 8390 insertions(+), 7308 deletions(-) delete mode 100644 .github/workflows/ci-pay-admin.yml create mode 100644 app-go/app-admin/internal/config/default_yaml_test.go create mode 100644 app-go/app-admin/internal/dto/console_user.go create mode 100644 app-go/app-admin/internal/dto/pay.go rename app-pay-admin/internal/model/model.go => app-go/app-admin/internal/model/pay.go (77%) rename {app-pay-admin/internal/appops => app-go/app-admin/internal/payops}/client.go (63%) create mode 100644 app-go/app-admin/internal/payops/client_test.go rename app-pay-admin/internal/repo/membership.go => app-go/app-admin/internal/repo/pay_membership.go (81%) rename app-pay-admin/internal/repo/order.go => app-go/app-admin/internal/repo/pay_order.go (80%) rename app-pay-admin/internal/repo/product.go => app-go/app-admin/internal/repo/pay_product.go (60%) rename app-pay-admin/internal/repo/refundapproval.go => app-go/app-admin/internal/repo/pay_refund.go (88%) create mode 100644 app-go/app-admin/internal/router/console_users.go create mode 100644 app-go/app-admin/internal/router/console_users_test.go create mode 100644 app-go/app-admin/internal/router/device.go create mode 100644 app-go/app-admin/internal/router/pay.go create mode 100644 app-go/app-admin/internal/router/pay_test.go create mode 100644 app-go/app-admin/internal/router/security_test.go create mode 100644 app-go/app-admin/internal/service/console_user.go create mode 100644 app-go/app-admin/internal/service/console_user_test.go rename app-pay-admin/internal/service/audit.go => app-go/app-admin/internal/service/pay_audit.go (65%) create mode 100644 app-go/app-admin/internal/service/pay_audit_test.go rename app-pay-admin/internal/service/grant.go => app-go/app-admin/internal/service/pay_grant.go (52%) create mode 100644 app-go/app-admin/internal/service/pay_query.go delete mode 100644 app-pay-admin/.gitignore delete mode 100644 app-pay-admin/Dockerfile delete mode 100644 app-pay-admin/README.md delete mode 100644 app-pay-admin/config.dev.yaml delete mode 100644 app-pay-admin/config.docker.yaml delete mode 100644 app-pay-admin/default.yaml delete mode 100644 app-pay-admin/go.mod delete mode 100644 app-pay-admin/go.sum delete mode 100644 app-pay-admin/internal/appops/actions.go delete mode 100644 app-pay-admin/internal/certgen/certgen.go delete mode 100644 app-pay-admin/internal/certgen/certgen_test.go delete mode 100644 app-pay-admin/internal/config/config.go delete mode 100644 app-pay-admin/internal/config/config_test.go delete mode 100644 app-pay-admin/internal/db/db.go delete mode 100644 app-pay-admin/internal/db/db_test.go delete mode 100644 app-pay-admin/internal/logger/logger.go delete mode 100644 app-pay-admin/internal/repo/user.go delete mode 100644 app-pay-admin/internal/router/api_test.go delete mode 100644 app-pay-admin/internal/router/console.go delete mode 100644 app-pay-admin/internal/router/grant.go delete mode 100644 app-pay-admin/internal/router/memberships.go delete mode 100644 app-pay-admin/internal/router/orders.go delete mode 100644 app-pay-admin/internal/router/pages.go delete mode 100644 app-pay-admin/internal/router/pages_events.go delete mode 100644 app-pay-admin/internal/router/pages_grants.go delete mode 100644 app-pay-admin/internal/router/pages_memberships.go delete mode 100644 app-pay-admin/internal/router/pages_orders.go delete mode 100644 app-pay-admin/internal/router/pages_products.go delete mode 100644 app-pay-admin/internal/router/pages_test.go delete mode 100644 app-pay-admin/internal/router/pages_users.go delete mode 100644 app-pay-admin/internal/router/products.go delete mode 100644 app-pay-admin/internal/router/refunds.go delete mode 100644 app-pay-admin/internal/router/refunds_test.go delete mode 100644 app-pay-admin/internal/router/router.go delete mode 100644 app-pay-admin/internal/router/users.go delete mode 100644 app-pay-admin/internal/router/webui.go delete mode 100644 app-pay-admin/internal/router/webui_test.go delete mode 100644 app-pay-admin/main.go delete mode 100644 app-pay-admin/scripts/gen-dev-cert.ps1 delete mode 100644 app-pay-admin/scripts/gen-dev-cert.sh delete mode 100644 app-pay-admin/web/assets/app.css delete mode 100644 app-pay-admin/web/assets/login-art.svg delete mode 100644 app-pay-admin/web/assets/logo.svg delete mode 100644 app-pay-admin/web/templates/base.tmpl delete mode 100644 app-pay-admin/web/templates/events.tmpl delete mode 100644 app-pay-admin/web/templates/grants.tmpl delete mode 100644 app-pay-admin/web/templates/login.tmpl delete mode 100644 app-pay-admin/web/templates/member_events.tmpl delete mode 100644 app-pay-admin/web/templates/memberships.tmpl delete mode 100644 app-pay-admin/web/templates/order_detail.tmpl delete mode 100644 app-pay-admin/web/templates/orders.tmpl delete mode 100644 app-pay-admin/web/templates/products.tmpl delete mode 100644 app-pay-admin/web/templates/refunds.tmpl delete mode 100644 app-pay-admin/web/templates/users.tmpl delete mode 100644 app-pay-admin/web/web.go delete mode 100644 deploy/docker/95-app-pay-admin.yml delete mode 100644 deploy/k8s/30-apps/app-pay-admin.yaml delete mode 100644 docs/app-pay-admin.md create mode 100644 frontend-admin/src/api/accounts.ts create mode 100644 frontend-admin/src/api/pay.ts create mode 100644 frontend-admin/src/components/pay-meta.ts create mode 100644 frontend-admin/src/device.ts create mode 100644 frontend-admin/src/pages/Accounts.tsx create mode 100644 frontend-admin/src/pages/pay/Events.tsx create mode 100644 frontend-admin/src/pages/pay/Grants.tsx create mode 100644 frontend-admin/src/pages/pay/MemberEvents.tsx create mode 100644 frontend-admin/src/pages/pay/Memberships.tsx create mode 100644 frontend-admin/src/pages/pay/OrderDetail.tsx create mode 100644 frontend-admin/src/pages/pay/Orders.tsx create mode 100644 frontend-admin/src/pages/pay/Products.tsx create mode 100644 frontend-admin/src/pages/pay/Refunds.tsx diff --git a/.env.example b/.env.example index 45c984f7..3989e9ed 100644 --- a/.env.example +++ b/.env.example @@ -244,10 +244,6 @@ PAY_MYSQL_PASSWORD=app-pay # registered executor_url query string, no app-task changes needed) PAY_EXECUTOR_TOKEN=generate-your-own-token_urlsafe-24 -# app-pay-admin console master API token (optional; scripts may call /api/* -# with admin equivalence) -# PAYADMIN__WEBUI__TOKEN= - # ============================================================ # SDK-read variables (exception: these SDKs read env vars only, never YAML) # ============================================================ @@ -269,9 +265,7 @@ LANGSMITH_ENDPOINT=https://api.smith.langchain.com # PAY_MYSQL_PORT=3307 # host-mapped port (local IDE/client debugging only) # APP_PAY_PORT=8002 # APP_PAY_TEST_PORT=18002 -# app-pay-admin -# APP_PAY_ADMIN_PORT=8003 -# app-admin(分享审核后台) +# app-admin(统一管理后台:分享审核 + 支付运营 + 沙箱 + 账户 + 审计) # APP_ADMIN_PORT=8016 # APP_ADMIN_WEB_PORT=3001 # APPADMIN__SECURITY__IP_ALLOWLIST=192.0.2.10/32,10.20.0.0/16 @@ -331,27 +325,23 @@ LANGSMITH_ENDPOINT=https://api.smith.langchain.com # app-pay P1 key separation (manually synced with apisix.yaml consumers): # PAY_OPS_KEY - dedicated to fund-write operations (/internal/pay/grant, -# order refunds); never injected into business containers +# order refunds); injected only into the admin console +# (app-admin-api refund review), never into business containers # PAY_READONLY_KEY - membership query read-only (/internal/pay/membership/*) # Generate the same way as APISIX_CONSUMER_KEY (token_urlsafe(36)) # PAY_READONLY_KEY= # PAY_OPS_KEY= -# pay-admin refund review workbench (P0): calls app-pay internal endpoints via -# the APISIX pay-ops consumer. -# ops_key = value of PAY_OPS_KEY (copy it over; empty = review feature hidden -# in the console). -# PAYADMIN__APP_PAY__OPS_KEY= -# PAYADMIN__APP_PAY__BASE_URL=http://apisix:9080 - # ============================================================ -# app-admin - share-moderation admin console (standalone Go service) +# app-admin - unified admin console (Go API; share moderation + payment ops +# + sandbox proxy + console accounts + audit) # ------------------------------------------------------------ # Prefix APPADMIN__; reads the repo-root .env (godotenv); non-sensitive # config lives in the service's embedded default.yaml (app-go/app-admin). # Direct-connects the shared bilirag MySQL (same RDBMS__URL as the other -# app-go services - no extra connection string needed); the only write onto -# shared tables is the four moderation columns of cloud_shares. +# app-go services - no extra connection string needed); writes onto shared +# tables are the four moderation columns of cloud_shares plus the controlled +# membership grant in app_pay. # ============================================================ # Seeded console admin account (insert-once on first boot; only the missing @@ -363,3 +353,28 @@ LANGSMITH_ENDPOINT=https://api.smith.langchain.com # RDBMS__URL: reused as-is (see "Shared secrets" near the top of this file); # docker-compose maps it into APPADMIN__RDBMS__URL automatically. + +# Login security (optional; defaults live in app-go/app-admin/default.yaml: +# device_binding=strict, ip_binding=subnet, 10 fails/min/IP + 10 fails/15min/account). +# The SPA sends a per-browser X-Device-Id header; scripts must do the same after +# logging in, otherwise device-bound sessions are rejected. +# APPADMIN__SESSION__DEVICE_BINDING=strict # strict | id | off +# APPADMIN__SESSION__IP_BINDING=subnet # exact | subnet | off +# APPADMIN__SECURITY__TRUSTED_PROXIES=172.16.0.0/12 # proxy CIDRs allowed to set X-Forwarded-* +# APPADMIN__SECURITY__LOGIN__MAX_FAILS_PER_IP=10 +# APPADMIN__SECURITY__LOGIN__ACCOUNT_WINDOW_SECONDS=900 + +# Payment ops (merged from the retired app-pay-admin, 2026-11-03). +# Second GORM connection to the app_pay database; the pay_* schema stays owned +# by app-pay (Flyway) and is never migrated here. Empty URL = payment pages +# return 503 while share moderation / accounts / sandbox keep working. +# docker-compose already derives this from PAY_MYSQL_*; override only for an +# external app-pay database. +# APPADMIN__PAY_RDBMS__URL=mysql+aiomysql://app_pay:app-pay@app-pay-mysql:3306/app_pay +# Refund review calls app-pay internal endpoints through the APISIX pay-ops +# consumer. Key = PAY_OPS_KEY (copy it over; empty = the refund-review group is +# hidden with 404, everything else keeps working). +# APPADMIN__PAY__OPS__BASE_URL=http://apisix:9080 +# APPADMIN__PAY__OPS__KEY= +# Fund audit JSONL (grants / refund confirmations, hash chain, 365 days). +# APPADMIN__PAY__AUDIT_FILE=/app/logs/pay-audit.jsonl diff --git a/.github/workflows/ci-pay-admin.yml b/.github/workflows/ci-pay-admin.yml deleted file mode 100644 index 1fc37da6..00000000 --- a/.github/workflows/ci-pay-admin.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: CI - Pay Admin - -on: - push: - branches: [main] - paths: - - "app-pay-admin/**" - - ".github/workflows/ci-pay-admin.yml" - pull_request: - paths: - - "app-pay-admin/**" - - ".github/workflows/ci-pay-admin.yml" - -jobs: - test: - runs-on: ubuntu-latest - defaults: - run: - working-directory: app-pay-admin - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: app-pay-admin/go.mod - cache-dependency-path: app-pay-admin/go.sum - - - name: Gofmt check - run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } - - - name: Go vet - run: go vet ./... - - - name: Go test - run: go test -count=1 ./... - - - name: Build - run: go build -trimpath -ldflags="-s -w" -o /tmp/app-pay-admin . diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 25bf0603..b6b74467 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -81,10 +81,6 @@ jobs: dockerfile: scripts/minio.Dockerfile image: atoncooper/mind-base-minio name: minio - - context: ./app-pay-admin - dockerfile: app-pay-admin/Dockerfile - image: atoncooper/mind-base-app-pay-admin - name: app-pay-admin - context: ./app-go/app-admin dockerfile: app-go/app-admin/Dockerfile image: atoncooper/mind-base-app-admin diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c86abfd..dddedfb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Unreleased] — 2026-11-03 +### Changed + +- **支付后台并入统一管理后台**:独立服务 `app-pay-admin`(Go + SSR WebUI,:8003,profile `pay-admin`)退役,支付运营能力(订单 / 会员 / 权益事件 / 回调流水 / SKU 查询、会员补偿开通、退款复核)并入 `app-admin`(`app-go/app-admin` API + `frontend-admin` SPA)。统一账户 `admin_user` / `admin_session` / `admin_audit`、统一角色 admin/viewer、统一入口 :3001(API :8016,均仅回环)。app-admin 新增第二 GORM 连接直连 `app_pay`(只读 + 会员补偿开通,唯一资金写);退款复核经 APISIX pay-ops consumer 调 app-pay internal 端点执行。配置迁移 `PAYADMIN__*` → `APPADMIN__PAY_*` / `APPADMIN__PAY__*`;资金审计 JSONL 统一为 `logs/pay-audit.jsonl`(哈希链,365 天)。旧 `payadmin_user` 账户不迁移(运维在「账户」页重建)。 +- **部署/CI 同步退役**:compose 删除 `app-pay-admin` 服务与 `app_pay_admin_logs` 卷、移除 `--profile pay-admin`;删除 `deploy/docker/95-app-pay-admin.yml`、`deploy/k8s/30-apps/app-pay-admin.yaml`、`.github/workflows/ci-pay-admin.yml` 与 docker-build 矩阵条目;`deploy/docker/96-app-admin.yml`、k8s `app-admin`、swarm stack 的 app-admin 注入支付库 / ops key / 资金审计路径。 +- **文档**:`docs/app-admin.md` 重写为统一后台(分享审核 + 支付运营 + 沙箱 + 统一账户/审计 + 配置/API/启动/迁移说明/红线);删除 `docs/app-pay-admin.md`;同步更新文档索引、架构、配置、部署、功能、API、FAQ 等引用。 +- **管理后台登录安全加固**:所有管理端请求携带 `X-Device-Id`,登录会话绑定设备指纹(SHA-256,strict/id/off 可配);会话 IP 绑定(exact/subnet,IPv4 /24、IPv6 /64);可信代理白名单解析真实客户端 IP;IP 级 + 账户级双限流,登录失败/新设备/新 IP/会话设备或 IP 不匹配全部写入审计;CORS 仅接受同源/精确 Origin(拒绝 `*`/`null`,放行 `X-Device-Id`),并增加 CORP/COOP 安全头。 + ### Documentation - **根 README 重写为项目介绍**:从「功能清单 + 目录索引 + 接口表」改为定位、核心能力、架构一览、快速开始与文档导航;结构、API、FAQ 分别迁入独立文档,避免与代码漂移。 diff --git a/app-go/app-admin/Dockerfile b/app-go/app-admin/Dockerfile index b5593c85..7ee00adb 100644 --- a/app-go/app-admin/Dockerfile +++ b/app-go/app-admin/Dockerfile @@ -1,26 +1,29 @@ -# ====== app-admin: share-moderation admin console (Go) ====== +# ====== app-admin: unified admin console (Go; share moderation + payment ops) ====== # Multi-stage build: compile static binary, ship on distroless. # FROM 按 digest 固定:BuildKit 每次都会联网解析 tag(GFW 下 auth.docker.io 超时 -# 直接失败),digest 引用命中本地镜像即可离线构建(同 app-pay-admin 的既有做法)。 +# 直接失败),digest 引用命中本地镜像即可离线构建(同其它 Go 服务的既有做法)。 # 升级基础镜像时:docker pull 后用 docker images --digests 取新 digest 替换。 FROM golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 AS builder WORKDIR /src -# Use China mirror for Go modules (goproxy.cn) with direct fallback. +# Module proxy is a build argument so restricted networks can override it: +# docker compose build --build-arg GOPROXY=https://mirrors.aliyun.com/goproxy/,direct app-admin-api # GOSUMDB off: sum.golang.org is often unreachable behind GFW. -ENV GOPROXY=https://goproxy.cn,direct +ARG GOPROXY=https://goproxy.cn,direct +ENV GOPROXY=${GOPROXY} ENV GOSUMDB=off -# Cache deps first -COPY go.mod go.sum* ./ -RUN go mod download || true - -# Copy source +# Copy source first: go build fetches only the modules imported by the +# production binary (test-only dependencies are not downloaded), which keeps +# the network footprint small on restricted connections. BuildKit cache mounts +# keep modules / compiled objects across retries when a fetch times out. COPY . . # Static build (CGO disabled for distroless) -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app-admin . \ +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app-admin . \ && mkdir -p /out/empty-logs # ---- runtime image ---- diff --git a/app-go/app-admin/README.md b/app-go/app-admin/README.md index 9c6b4088..c653a86b 100644 --- a/app-go/app-admin/README.md +++ b/app-go/app-admin/README.md @@ -1,20 +1,20 @@ -# app-admin — 云盘分享审核管理后台(Go + Gin) +# app-admin — 统一管理后台(Go + Gin) -MindBase 的**分享审核**后台服务(plan/1.0.16):直连共享 bilirag MySQL,只读用户账户 / 分享元数据,唯一写操作是 `cloud_shares` 的 4 个审核列(ban / unban / approve)。配套前端 `frontend-admin/`(Vite + React + antd)。 +MindBase 的**统一管理后台**:分享审核+ 支付运营(原独立服务 app-pay-admin,2026-11-03 合并)+ 控制台账户管理 + 沙箱控制台代理。直连共享 bilirag MySQL;支付运营使用**第二 GORM 连接**直连 `app_pay`(schema 归 app-pay/Java Flyway,本服务绝不 migrate)。配套前端 `frontend-admin/`(Vite + React + antd)。 -- 使用与 API 文档:[docs/app-admin.md](../../docs/app-admin.md);设计契约:[plan/1.0.16-ShareModeration](../../plan/1.0.16-ShareModeration/1.0.16-ShareModeration.md) -- 架构/红线:根 `AGENTS.md` §2.21 +- 使用与 API 文档:[docs/app-admin.md](../../docs/app-admin.md);架构/红线:根 `AGENTS.md` - Go API 端口 `8016`,**仅回环**、不经主站 APISIX/nginx;完整后台入口是 Compose `app-admin`(UI :3001)+ `app-admin-api`(本服务 API :8016) ## 职责边界 - **读**:`users` / `user_profile` / `rbac_user_role` / `rbac_role` / `cloud_shares` / `cloud_files` / `cloud_folders`(仅元数据) -- **写**:`cloud_shares.moderation_status/reason/time/by`(同事务写 `admin_audit`) +- **写**:`cloud_shares.moderation_status/reason/time/by`(同事务写 `admin_audit`);会员补偿开通(唯一资金写:`pay_membership` + `pay_membership_event` 同事务,复刻 app-pay `MembershipService.extend` 语义) - **自有表**(`Migrate` 只 `CREATE TABLE IF NOT EXISTS`,守护测试锁定):`admin_user` / `admin_session` / `admin_audit` +- **支付库(第二连接,只读 + 受控写)**:`pay_order` / `pay_membership` / `pay_membership_event` / `pay_callback_log` / `pay_product` / `pay_refund_approval`;`pay_*` schema 归 app-pay(Java Flyway),**绝不 AutoMigrate/ALTER**;退款复核只读审批表,approve/reject 经 app-pay internal 端点执行 - ❌ 无 MinIO/对象读取、无 `AutoMigrate`/ALTER 共享表、不触碰 `is_revoked`;文件内容仅经 app-cloud 内部审核预览端点按需读取(**审核员可查看任意分享**,每次预览落审计,无下载) -- **沙箱管理(plan/1.0.19)**:不直连 `sandbox_*` 表,`/api/sandbox/*` 全部代理到 app-sandbox `/internal/admin/*`(`X-Admin-Token`);写操作仅 admin 且落本服务审计(`sandbox.execution.cancel` / `sandbox.lease.release`),沙箱状态以控制层为唯一真相 +- **沙箱管理**:不直连 `sandbox_*` 表,`/api/sandbox/*` 全部代理到 app-sandbox `/internal/admin/*`(`X-Admin-Token`);写操作仅 admin 且落本服务审计(`sandbox.execution.cancel` / `sandbox.lease.release`),沙箱状态以控制层为唯一真相 -## 沙箱控制台代理(plan/1.0.19 §四) +## 沙箱控制台代理 app-sandbox 控制层是沙箱资源的唯一真相;本服务只做**鉴权 + 透传 + 审计**,不新增查询语义、不重新映射上游字段(上游加字段无需改本服务)。 @@ -39,6 +39,31 @@ APPADMIN__SANDBOX_ADMIN__BASE_URL=http://app-sandbox:8017 APPADMIN__SANDBOX_ADMIN__TOKEN= # 与 app-sandbox SANDBOX__ADMIN__TOKEN 同一共享密钥,不下发浏览器 ``` +## 支付运营(app-pay 合并,2026-11-03) + +支付域能力:订单 / 回调流水 / 会员 / 权益事件 / SKU 只读查询 + 会员补偿开通(唯一资金写)+ 退款双人复核(maker-checker)。**所有支付相关 uid 在 JSON 中一律字符串**(bigint 精度安全),金额为整数分。 + +| 方法 | 路径 | 角色 | 说明 | +|---|---|---|---| +| GET | `/api/pay/orders`(uid/status/channel/sku/orderNo/tradeNo/createdFrom/createdTo/page/pageSize) | viewer+ | 订单列表(id DESC) | +| GET | `/api/pay/orders/:orderNo?tradeNo=` | viewer+ | 订单详情(orderNo 查不到用 tradeNo 兜底;404 order not found) | +| GET | `/api/pay/orders/:orderNo/callbacks` | viewer+ | 回调流水(脱敏报文 + 验签/处理结论) | +| GET | `/api/pay/memberships?uid=` | viewer+ | 带 uid 精确查(无记录 `membership:null`);不带 uid 分页 | +| GET | `/api/pay/memberships/:uid/events` | viewer+ | 单人权益事件流 | +| GET | `/api/pay/events?uid=&type=` | viewer+ | 全局权益事件流(ADMIN_GRANT 审计) | +| GET | `/api/pay/products` | viewer+ | SKU 目录(sort ASC, id ASC) | +| POST | `/api/pay/grants` | admin | 补偿开通(事务 + `SELECT ... FOR UPDATE`(MySQL) + 日额度熔断 429) | +| GET | `/api/pay/refunds?status=` | admin | 退款复核列表(未配置整组 404 隐身) | +| POST | `/api/pay/refunds/:orderNo/approve`、`/reject` | admin | 经 app-pay internal 端点执行(checker=登录用户名);上游失败 502 | + +**关闭/降级语义**:未配置 `pay_rdbms.url` 或 PayDB 初始化失败 → 所有 `/api/pay/**` 登录后 503 `{"detail":"支付管理未配置"}`,其余后台不受影响;`/health` 相应返回 503 `pay database unavailable`。退款复核另需 `pay.ops.base_url` + `pay.ops.key` + 开关同时就绪,否则整组 404。 + +**审计三留痕**(资金写):① `pay_membership_event.reason` 以 `[操作人] ` 前缀(事务内);② `pay-audit.jsonl`(SHA-256 哈希链,lumberjack 轮转 365 天,`MEMBERSHIP_EXTENDED` / `REFUND_*` / `GRANT_DAILY_*`);③ `admin_audit`(`pay_grant` / `pay_refund_approve` / `pay_refund_reject`;跨库无法同事务,失败仅告警,不把已落库的资金操作报成失败)。 + +## 控制台账户管理 + +`/api/console/users`(admin only,**独立于支付库**,pay 关闭也可用):列表(username ASC)/ 新建(2..64 字符、密码 8..128、role admin|viewer 默认 viewer)/ 重置密码(强制下次登录改密 + 删除该账户全部会话)/ 删除(不能删自己、不能删最后一个 admin)。与只读业务用户查询 `GET /api/users` 完全分离。 + ## 启动 ```bash @@ -57,15 +82,32 @@ APPADMIN__RDBMS__URL='mysql+aiomysql://mind_base:mind-base@127.0.0.1:3306/mind_b ```bash APPADMIN__RDBMS__URL=... # 必填(compose 由 RDBMS__URL 注入) +APPADMIN__PAY_RDBMS__URL=... # 可选;app_pay 第二连接(compose 由 PAY_MYSQL_* 注入),空=支付运营 503 +APPADMIN__PAY__OPS__BASE_URL=http://apisix:9080 # 退款复核上游(与 key 同时配置才启用) +APPADMIN__PAY__OPS__KEY=... # PAY_OPS_KEY(pay-ops consumer);空=退款复核 404 隐身 +APPADMIN__PAY__GRANT__DAILY_MAX_COUNT=50 # 补偿开通日额度(0=不限,对齐 app-pay) +APPADMIN__PAY__GRANT__DAILY_MAX_DAYS=1000 +APPADMIN__PAY__AUDIT_FILE=/app/logs/pay-audit.jsonl APPADMIN__SEED__USERNAME=admin # 只插缺失 APPADMIN__SEED__PASSWORD=app-admin # 生产务必覆盖 APPADMIN__SESSION__TTL_MINUTES=720 +# 登录安全:设备指纹(strict/id/off)、会话 IP 绑定(subnet/exact/off) +APPADMIN__SESSION__DEVICE_BINDING=strict +APPADMIN__SESSION__IP_BINDING=subnet +APPADMIN__SESSION__IPV4_PREFIX=24 +APPADMIN__SESSION__IPV6_PREFIX=64 +# 可信反向代理(只有这些 peer 的 X-Forwarded-* 被采信;compose 默认 UI 网段) +APPADMIN__SECURITY__TRUSTED_PROXIES=172.16.0.0/12 +APPADMIN__SECURITY__LOGIN__MAX_FAILS_PER_IP=10 +APPADMIN__SECURITY__LOGIN__MAX_FAILS_PER_ACCOUNT=10 APPADMIN__SECURITY__IP_ALLOWLIST=192.0.2.10/32,10.20.0.0/16 # 可选;空=由部署网络边界控制 APPADMIN__SECURITY__CORS__ALLOW_ORIGINS=https://admin.example.com # 仅跨域部署需要,精确 Origin APPADMIN__LOG__OUTPUT=both # stdout|file|both ``` -IP 白名单匹配服务看到的 TCP 对端地址;当前不信任 `X-Forwarded-For`。经过 SSH 隧道、容器 NAT 或代理访问时,应用看到的可能是隧道/代理地址而非浏览器真实 IP,此时应在 SSH、防火墙或可信入口代理处限制真实来源。CORS 只约束浏览器跨域,不替代网络访问控制;浏览器写请求还必须带同源或白名单 `Origin`。`/health` 为容器与集群探针保留,不受 IP 白名单限制。 +IP 白名单匹配服务看到的 TCP 对端地址,且**不采信 `X-Forwarded-For`**;登录限流、会话 IP 绑定与审计则使用可信代理链解析出的真实客户端 IP(`APPADMIN__SECURITY__TRUSTED_PROXIES` 内的 peer 才可信)。CORS 只约束浏览器跨域,不替代网络访问控制;浏览器写请求还必须带同源或白名单 `Origin`。`/health` 为容器与集群探针保留,不受 IP 白名单限制。 + +**设备指纹**:所有 `/api/*` 请求必须带 `X-Device-Id`(16–128 位 `[A-Za-z0-9_-]`)。浏览器 SPA 自动生成并持久化在 localStorage,服务端只存其与 UA/语言组成的 SHA-256 指纹并绑定会话;`device_binding=strict` 时更换设备、清存储或 UA 变化会要求重新登录。脚本调用需先带该头登录,之后每次请求同时带 cookie 与相同设备 ID。 ## 测试 @@ -77,13 +119,14 @@ cd app-go/app-admin && go test ./... # gofmt / vet / build 同 CI(.github/wo ``` main.go / default.yaml / Dockerfile # 入口 + 嵌入配置 + 多阶段构建(distroless) -internal/config YAML + APPADMIN__ env + godotenv + Validate +internal/config YAML + APPADMIN__ env + godotenv + Validate + Normalize*(可选平面半配置清理) internal/dto 纯 JSON 响应 DTO(契约形状,无依赖;service 组装 / router 序列化) -internal/db GORM MySQL + 仅自有三表 Migrate + Ping +internal/db 主库 GORM MySQL + 可选 PayDB 第二连接(PayInit/PayPing/PayAvailable)+ 仅自有三表 Migrate internal/logger slog + Gin 访问日志/恢复 + lumberjack 文件 sink -internal/model 自有表 + 共享表只读映射(绝不迁移) -internal/repo 查询(users/shares/audit/会话)+ 唯一受控写 UPDATE -internal/service 业务装配(分页/DTO/审核语义/登录会话/限流/沙箱代理审计) +internal/model 自有表 + 共享表只读映射(绝不迁移)+ pay_* 只读映射 +internal/repo 查询(users/shares/audit/会话/支付域)+ 受控写(审核列 / 账户) +internal/service 业务装配(分页/DTO/审核语义/登录会话/限流/沙箱代理审计/支付查询/补偿开通/JSONL 资金审计/账户规则) +internal/payops app-pay internal 退款复核端点客户端(apikey + 错误 detail 透传) internal/sandbox app-sandbox /internal/admin/* 出站客户端(X-Admin-Token + 错误映射) -internal/router Gin 路由(/health + /api/*)+ session 闸门 + admin 闸门 + CORS/安全头 +internal/router Gin 路由(/health + /api/*)+ session 闸门 + admin 闸门 + pay/refund 闸门 + CORS/安全头 ``` diff --git a/app-go/app-admin/default.yaml b/app-go/app-admin/default.yaml index 5d683dd5..e63bbff5 100644 --- a/app-go/app-admin/default.yaml +++ b/app-go/app-admin/default.yaml @@ -16,14 +16,40 @@ rdbms: max_open_conns: 10 max_idle_conns: 5 conn_max_lifetime: 1800 -# Login session lifetime (DB-backed admin_session, sliding on each request). +# OPTIONAL second database: app_pay on the dedicated app-pay-mysql instance +# (host port 3307). When url is empty (or the connection cannot be opened) the +# payment-operations console answers 503 {"detail":"支付管理未配置"} while every +# other page keeps working. The pay_* schema is owned by app-pay (Java Flyway): +# app-admin NEVER migrates it — only reads plus the controlled membership grant. +# docker-compose injects APPADMIN__PAY_RDBMS__URL from PAY_MYSQL_USER/PASSWORD. +pay_rdbms: + url: "" # env: APPADMIN__PAY_RDBMS__URL + max_open_conns: 10 + max_idle_conns: 5 + conn_max_lifetime: 1800 +# Login session lifetime (DB-backed admin_session, sliding on each request) +# plus the binding rules re-checked on every authenticated request: +# device_binding: off (ignore) | id (device id only) | strict (id + UA/语言) +# ip_binding: off | subnet (同一网段) | exact (完全一致) +# Empty binding values mean off. Old sessions without a device hash are +# invalidated as soon as device binding is enabled. session: ttl_minutes: 720 + device_binding: strict + ip_binding: subnet + ipv4_prefix: 24 + ipv6_prefix: 64 security: # Optional exact IPs/CIDRs for the admin API. Empty leaves network access # control to loopback binding / firewall / VPN / Kubernetes networking. # Environment form: APPADMIN__SECURITY__IP_ALLOWLIST=10.0.0.10/32,192.168.1.0/24 ip_allowlist: [] + # Reverse proxies (IP or CIDR) whose X-Forwarded-For / X-Forwarded-Proto / + # X-Forwarded-Host headers may be believed. Only a TCP peer inside this list + # has its forwarded headers parsed; empty = never trust them, so a client + # cannot spoof its address or HTTPS state. + # Environment form: APPADMIN__SECURITY__TRUSTED_PROXIES=172.16.0.0/12 + trusted_proxies: [] cors: # Cross-origin whitelist (exact Origin match). The admin SPA is same-origin # in production. Unsafe browser methods also require an allowed Origin as a @@ -31,6 +57,13 @@ security: # frontends must be explicitly listed. # APPADMIN__SECURITY__CORS__ALLOW_ORIGINS=http://127.0.0.1:3001 allow_origins: [] + # 登录失败限流:IP 级短窗口防单机狂刷,账户级长窗口防换 IP 撞库;两个预算 + # 每次登录都检查,任一超限即拒绝(不消耗另一预算)。 + login: + max_fails_per_ip: 10 + ip_window_seconds: 60 + max_fails_per_account: 10 + account_window_seconds: 900 # app-cloud internal share-content review preview. BOTH keys required; # anything missing disables the preview endpoint (fail closed). Generate a # dedicated token (openssl rand -hex 32) and put the SAME value in app-cloud's @@ -47,6 +80,25 @@ cloud_review: sandbox_admin: base_url: "" # env: APPADMIN__SANDBOX_ADMIN__BASE_URL (e.g. http://app-sandbox:8017) token: "" # env: APPADMIN__SANDBOX_ADMIN__TOKEN +# 支付运营(app-pay 联动) +pay: + # 补偿开通日额度(对齐 app-pay GrantQuotaService):app-admin 直写 + # pay_membership_event,必须自己执行同样的熔断,否则绕过执行点。0 = 不限。 + grant: + daily_max_count: 50 + daily_max_days: 1000 + # 退款复核工作台:资金写只发生在 app-pay 的 internal 端点(审计/状态机/ + # 审批表一致性)。base_url + key 同时配置且开关打开才启用;否则整组 404 + # (隐身,不可探测)。key 走 APISIX key-auth(pay-ops consumer,PAY_OPS_KEY), + # 与登录身份(operator)一起下传。 + ops: + base_url: "" # env: APPADMIN__PAY__OPS__BASE_URL(compose 默认 http://apisix:9080) + key: "" # env: APPADMIN__PAY__OPS__KEY + timeout_seconds: 5 + refund_review_enabled: true + # 资金操作 JSONL 审计(SHA-256 哈希链,lumberjack 轮转,365 天),与 app-pay + # 的 pay-audit.jsonl 同约定:直写 DB 绕过了 Java 侧 PayAuditLogger,这里补留痕。 + audit_file: "/app/logs/pay-audit.jsonl" # env: APPADMIN__PAY__AUDIT_FILE # Seeded default admin account, inserted once (missing username only) with # must_change_password=1 so the first login is forced to change it. seed: diff --git a/app-go/app-admin/go.mod b/app-go/app-admin/go.mod index af42b686..524788d4 100644 --- a/app-go/app-admin/go.mod +++ b/app-go/app-admin/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.48.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -18,14 +19,17 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -37,6 +41,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect @@ -45,4 +50,8 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/app-go/app-admin/go.sum b/app-go/app-admin/go.sum index b33ad29d..1105a787 100644 --- a/app-go/app-admin/go.sum +++ b/app-go/app-admin/go.sum @@ -11,12 +11,18 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -34,6 +40,10 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -67,6 +77,9 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -115,3 +128,11 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/app-go/app-admin/internal/config/config.go b/app-go/app-admin/internal/config/config.go index c03f5c40..e37f5c1a 100644 --- a/app-go/app-admin/internal/config/config.go +++ b/app-go/app-admin/internal/config/config.go @@ -20,12 +20,20 @@ import ( ) type Config struct { - App AppConfig `yaml:"app"` - Server ServerConfig `yaml:"server"` - Timezone string `yaml:"timezone"` - RDBMS RDBMSConfig `yaml:"rdbms"` + App AppConfig `yaml:"app"` + Server ServerConfig `yaml:"server"` + Timezone string `yaml:"timezone"` + RDBMS RDBMSConfig `yaml:"rdbms"` + // PayRDBMS is the OPTIONAL second connection: app_pay on the dedicated + // app-pay-mysql instance (pay_* schema owned by app-pay / Java Flyway). + // Empty url = payment console disabled (/api/pay/** answers 503) while the + // rest of the console keeps working. + PayRDBMS RDBMSConfig `yaml:"pay_rdbms"` Session SessionConfig `yaml:"session"` Security SecurityConfig `yaml:"security"` + // Pay configures the payment-operations console (grant quota, refund-review + // upstream, fund-audit JSONL path). + Pay PayConfig `yaml:"pay"` // CloudReview — app-cloud internal preview wiring (see CloudReviewConfig). CloudReview CloudReviewConfig `yaml:"cloud_review"` // SandboxAdmin — app-sandbox internal admin plane (see SandboxAdminConfig). @@ -52,16 +60,76 @@ type RDBMSConfig struct { } // SessionConfig: DB-backed login session lifetime (admin_session table, -// HttpOnly cookie, sliding expiry on each authenticated request). +// HttpOnly cookie, sliding expiry on each authenticated request) plus the +// device / IP binding rules re-checked whenever a session is presented. type SessionConfig struct { - TTLMinutes int `yaml:"ttl_minutes"` // 0 = default 12h + TTLMinutes int `yaml:"ttl_minutes"` // 0 = default 12h + DeviceBinding string `yaml:"device_binding"` // ""|off|id|strict + IPBinding string `yaml:"ip_binding"` // ""|off|subnet|exact + IPv4Prefix int `yaml:"ipv4_prefix"` // 0 = default 24 + IPv6Prefix int `yaml:"ipv6_prefix"` // 0 = default 64 +} + +// DeviceBindingMode normalizes the device-fingerprint policy. Only id and +// strict enable the check; empty or unknown values mean off (no header +// required, no session device comparison). +func (s SessionConfig) DeviceBindingMode() string { + switch mode := strings.ToLower(strings.TrimSpace(s.DeviceBinding)); mode { + case "id", "strict": + return mode + default: + return "off" + } +} + +// IPBindingMode normalizes the session IP policy. Only exact and subnet enable +// the check; everything else means off. +func (s SessionConfig) IPBindingMode() string { + switch mode := strings.ToLower(strings.TrimSpace(s.IPBinding)); mode { + case "exact", "subnet": + return mode + default: + return "off" + } +} + +// IPv4PrefixOr returns the effective IPv4 subnet size with the documented +// default applied when the field is unset or out of range. +func (s SessionConfig) IPv4PrefixOr() int { + if s.IPv4Prefix >= 1 && s.IPv4Prefix <= 32 { + return s.IPv4Prefix + } + return 24 +} + +// IPv6PrefixOr returns the effective IPv6 subnet size (default 64). +func (s SessionConfig) IPv6PrefixOr() int { + if s.IPv6Prefix >= 1 && s.IPv6Prefix <= 128 { + return s.IPv6Prefix + } + return 64 +} + +// LoginConfig bounds repeated failed logins. Both budgets are enforced on every +// attempt: the short IP window stops one host hammering the endpoint, while the +// longer account window protects a single account across rotating addresses. +type LoginConfig struct { + MaxFailsPerIP int `yaml:"max_fails_per_ip"` // 0 = default 10 + IPWindowSeconds int `yaml:"ip_window_seconds"` // 0 = default 60 + MaxFailsPerAccount int `yaml:"max_fails_per_account"` // 0 = default 10 + AccountWindowSeconds int `yaml:"account_window_seconds"` // 0 = default 900 } type SecurityConfig struct { IPAllowlist []string `yaml:"ip_allowlist"` - CORS struct { + // TrustedProxies lists the reverse proxies whose X-Forwarded-* headers may + // be believed (IP or CIDR). Empty = forwarded headers are never trusted and + // the TCP peer address is the client address. + TrustedProxies []string `yaml:"trusted_proxies"` + CORS struct { AllowOrigins []string `yaml:"allow_origins"` } `yaml:"cors"` + Login LoginConfig `yaml:"login"` } // CloudReviewConfig wires the app-cloud internal share-content review preview. @@ -83,6 +151,33 @@ type SandboxAdminConfig struct { Token string `yaml:"token"` } +// PayConfig configures the payment-operations console. +type PayConfig struct { + Grant PayGrantConfig `yaml:"grant"` + Ops PayOpsConfig `yaml:"ops"` + AuditFile string `yaml:"audit_file"` +} + +// PayGrantConfig mirrors app-pay GrantQuotaService's daily cap: app-admin +// writes pay_membership_event directly, so it must enforce the same circuit +// breaker or the console grant would bypass the execution point. 0 = unlimited. +type PayGrantConfig struct { + DailyMaxCount int `yaml:"daily_max_count"` + DailyMaxDays int `yaml:"daily_max_days"` +} + +// PayOpsConfig wires the refund-review workbench to app-pay's internal +// endpoints. The fund write happens in app-pay (audit/state-machine +// consistency); the console carries PAY_OPS_KEY through APISIX key-auth +// (pay-ops consumer) plus the logged-in operator identity. key empty = the +// whole refund-review group stays hidden (404). +type PayOpsConfig struct { + BaseURL string `yaml:"base_url"` // default http://apisix:9080 (compose injects) + Key string `yaml:"key"` // PAY_OPS_KEY (pay-ops consumer) + TimeoutSeconds int `yaml:"timeout_seconds"` // default 5 + RefundReviewEnabled bool `yaml:"refund_review_enabled"` // master switch +} + // SeedConfig: default admin account inserted on first boot (missing username // only; never overwrites an existing row). must_change_password is forced to 1 // so the first login must POST /api/me/password. @@ -184,11 +279,26 @@ func applyEnvOverrides(cfg *Config) { } atoi("APPADMIN__SERVER__PORT", &cfg.Server.Port) atoi("APPADMIN__SESSION__TTL_MINUTES", &cfg.Session.TTLMinutes) + if v := get("APPADMIN__SESSION__DEVICE_BINDING"); v != "" { + cfg.Session.DeviceBinding = v + } + if v := get("APPADMIN__SESSION__IP_BINDING"); v != "" { + cfg.Session.IPBinding = v + } + atoi("APPADMIN__SESSION__IPV4_PREFIX", &cfg.Session.IPv4Prefix) + atoi("APPADMIN__SESSION__IPV6_PREFIX", &cfg.Session.IPv6Prefix) // Security lists accept comma-separated environment values; an explicitly // empty value clears a list supplied by a YAML overlay. if v, ok := os.LookupEnv("APPADMIN__SECURITY__IP_ALLOWLIST"); ok { cfg.Security.IPAllowlist = splitList(v) } + if v, ok := os.LookupEnv("APPADMIN__SECURITY__TRUSTED_PROXIES"); ok { + cfg.Security.TrustedProxies = splitList(v) + } + atoi("APPADMIN__SECURITY__LOGIN__MAX_FAILS_PER_IP", &cfg.Security.Login.MaxFailsPerIP) + atoi("APPADMIN__SECURITY__LOGIN__IP_WINDOW_SECONDS", &cfg.Security.Login.IPWindowSeconds) + atoi("APPADMIN__SECURITY__LOGIN__MAX_FAILS_PER_ACCOUNT", &cfg.Security.Login.MaxFailsPerAccount) + atoi("APPADMIN__SECURITY__LOGIN__ACCOUNT_WINDOW_SECONDS", &cfg.Security.Login.AccountWindowSeconds) if v, ok := os.LookupEnv("APPADMIN__SECURITY__CORS__ALLOW_ORIGINS"); ok { cfg.Security.CORS.AllowOrigins = splitList(v) } @@ -204,6 +314,26 @@ func applyEnvOverrides(cfg *Config) { if v := get("APPADMIN__SANDBOX_ADMIN__TOKEN"); v != "" { cfg.SandboxAdmin.Token = v } + // Payment operations: second database + grant quota + refund-review upstream. + if v := get("APPADMIN__PAY_RDBMS__URL"); v != "" { + cfg.PayRDBMS.URL = v + } + atoi("APPADMIN__PAY_RDBMS__MAX_OPEN_CONNS", &cfg.PayRDBMS.MaxOpenConns) + atoi("APPADMIN__PAY_RDBMS__MAX_IDLE_CONNS", &cfg.PayRDBMS.MaxIdleConns) + atoi("APPADMIN__PAY_RDBMS__CONN_MAX_LIFETIME", &cfg.PayRDBMS.ConnMaxLifetime) + atoi("APPADMIN__PAY__GRANT__DAILY_MAX_COUNT", &cfg.Pay.Grant.DailyMaxCount) + atoi("APPADMIN__PAY__GRANT__DAILY_MAX_DAYS", &cfg.Pay.Grant.DailyMaxDays) + if v := get("APPADMIN__PAY__OPS__BASE_URL"); v != "" { + cfg.Pay.Ops.BaseURL = v + } + if v := get("APPADMIN__PAY__OPS__KEY"); v != "" { + cfg.Pay.Ops.Key = v + } + atoi("APPADMIN__PAY__OPS__TIMEOUT_SECONDS", &cfg.Pay.Ops.TimeoutSeconds) + boolVar("APPADMIN__PAY__OPS__REFUND_REVIEW_ENABLED", &cfg.Pay.Ops.RefundReviewEnabled) + if v := get("APPADMIN__PAY__AUDIT_FILE"); v != "" { + cfg.Pay.AuditFile = v + } if v := get("APPADMIN__SEED__USERNAME"); v != "" { cfg.Seed.Username = v } @@ -250,6 +380,14 @@ func Validate(cfg *Config) error { return fmt.Errorf("security.cors.allow_origins entry %q: %w", origin, err) } } + for _, entry := range cfg.Security.TrustedProxies { + if err := validateIPOrCIDR(entry); err != nil { + return fmt.Errorf("security.trusted_proxies entry %q: %w", entry, err) + } + } + if err := validateSessionPolicy(cfg); err != nil { + return err + } // The optional upstream planes (cloud review preview, sandbox console proxy) // are NOT validated here: a lone base URL or token would silently half-enable // one, but blocking the whole console over an optional feature is worse — @@ -258,6 +396,32 @@ func Validate(cfg *Config) error { return nil } +// validateSessionPolicy rejects unknown binding modes and out-of-range subnet +// sizes. Zero prefixes mean "unset" and are normalized to the defaults. +func validateSessionPolicy(cfg *Config) error { + switch strings.ToLower(strings.TrimSpace(cfg.Session.DeviceBinding)) { + case "", "off", "id", "strict": + default: + return fmt.Errorf("session.device_binding must be off, id or strict, got %q", cfg.Session.DeviceBinding) + } + switch strings.ToLower(strings.TrimSpace(cfg.Session.IPBinding)) { + case "", "off", "subnet", "exact": + default: + return fmt.Errorf("session.ip_binding must be off, subnet or exact, got %q", cfg.Session.IPBinding) + } + if cfg.Session.IPv4Prefix == 0 { + cfg.Session.IPv4Prefix = 24 + } else if cfg.Session.IPv4Prefix < 1 || cfg.Session.IPv4Prefix > 32 { + return fmt.Errorf("session.ipv4_prefix must be between 1 and 32, got %d", cfg.Session.IPv4Prefix) + } + if cfg.Session.IPv6Prefix == 0 { + cfg.Session.IPv6Prefix = 64 + } else if cfg.Session.IPv6Prefix < 1 || cfg.Session.IPv6Prefix > 128 { + return fmt.Errorf("session.ipv6_prefix must be between 1 and 128, got %d", cfg.Session.IPv6Prefix) + } + return nil +} + // NormalizeCloudReview resolves the all-or-nothing invariant at startup: // when base URL and token are not BOTH set, both are cleared (preview // disabled, fail closed) and true is returned so main can warn loudly. @@ -289,6 +453,22 @@ func NormalizeSandboxAdmin(cfg *Config) bool { return true // half-wired → cleared + warned } +// NormalizePayOps resolves the all-or-nothing invariant for the refund-review +// workbench: the upstream base URL and the ops key must be set TOGETHER. Half +// wiring is cleared (the workbench stays hidden, 404) and true is returned so +// main can warn loudly; a fully wired or fully empty config returns false. +func NormalizePayOps(cfg *Config) bool { + base, key := strings.TrimSpace(cfg.Pay.Ops.BaseURL), strings.TrimSpace(cfg.Pay.Ops.Key) + if base != "" && key != "" { + return false // fully wired + } + if base == "" && key == "" { + return false // intentionally disabled + } + cfg.Pay.Ops.BaseURL, cfg.Pay.Ops.Key = "", "" + return true // half-wired → cleared + warned +} + func splitList(value string) []string { var result []string for _, item := range strings.Split(value, ",") { diff --git a/app-go/app-admin/internal/config/config_test.go b/app-go/app-admin/internal/config/config_test.go index ba3289c3..5f6a1aed 100644 --- a/app-go/app-admin/internal/config/config_test.go +++ b/app-go/app-admin/internal/config/config_test.go @@ -161,3 +161,181 @@ func TestValidateSandboxAdminAllOrNothing(t *testing.T) { t.Fatal("both empty is intentionally disabled, not a mismatch") } } + +// Payment-operations config: the second database, grant quota and the +// refund-review upstream all map onto APPADMIN__PAY_* env vars. +func TestApplyPayEnvOverrides(t *testing.T) { + t.Setenv("APPADMIN__PAY_RDBMS__URL", "mysql+aiomysql://u:p@app-pay-mysql:3306/app_pay") + t.Setenv("APPADMIN__PAY_RDBMS__MAX_OPEN_CONNS", "7") + t.Setenv("APPADMIN__PAY_RDBMS__MAX_IDLE_CONNS", "3") + t.Setenv("APPADMIN__PAY_RDBMS__CONN_MAX_LIFETIME", "600") + t.Setenv("APPADMIN__PAY__GRANT__DAILY_MAX_COUNT", "12") + t.Setenv("APPADMIN__PAY__GRANT__DAILY_MAX_DAYS", "345") + t.Setenv("APPADMIN__PAY__OPS__BASE_URL", "http://apisix:9080") + t.Setenv("APPADMIN__PAY__OPS__KEY", "pay-ops-key") + t.Setenv("APPADMIN__PAY__OPS__TIMEOUT_SECONDS", "9") + t.Setenv("APPADMIN__PAY__OPS__REFUND_REVIEW_ENABLED", "true") + t.Setenv("APPADMIN__PAY__AUDIT_FILE", "/app/logs/pay-audit.jsonl") + + var cfg Config + applyEnvOverrides(&cfg) + + if cfg.PayRDBMS.URL != "mysql+aiomysql://u:p@app-pay-mysql:3306/app_pay" || + cfg.PayRDBMS.MaxOpenConns != 7 || cfg.PayRDBMS.MaxIdleConns != 3 || cfg.PayRDBMS.ConnMaxLifetime != 600 { + t.Fatalf("pay_rdbms override lost: %+v", cfg.PayRDBMS) + } + if cfg.Pay.Grant.DailyMaxCount != 12 || cfg.Pay.Grant.DailyMaxDays != 345 { + t.Fatalf("pay.grant override lost: %+v", cfg.Pay.Grant) + } + if cfg.Pay.Ops.BaseURL != "http://apisix:9080" || cfg.Pay.Ops.Key != "pay-ops-key" || + cfg.Pay.Ops.TimeoutSeconds != 9 || !cfg.Pay.Ops.RefundReviewEnabled { + t.Fatalf("pay.ops override lost: %+v", cfg.Pay.Ops) + } + if cfg.Pay.AuditFile != "/app/logs/pay-audit.jsonl" { + t.Fatalf("pay.audit_file override lost: %q", cfg.Pay.AuditFile) + } +} + +// Session device / IP binding and the login throttle budgets map onto +// APPADMIN__SESSION__* / APPADMIN__SECURITY__LOGIN__* env vars. +func TestApplySessionSecurityEnvOverrides(t *testing.T) { + t.Setenv("APPADMIN__SESSION__DEVICE_BINDING", "id") + t.Setenv("APPADMIN__SESSION__IP_BINDING", "exact") + t.Setenv("APPADMIN__SESSION__IPV4_PREFIX", "20") + t.Setenv("APPADMIN__SESSION__IPV6_PREFIX", "56") + t.Setenv("APPADMIN__SECURITY__TRUSTED_PROXIES", " 172.16.0.0/12 , 10.1.2.3 ") + t.Setenv("APPADMIN__SECURITY__LOGIN__MAX_FAILS_PER_IP", "3") + t.Setenv("APPADMIN__SECURITY__LOGIN__IP_WINDOW_SECONDS", "30") + t.Setenv("APPADMIN__SECURITY__LOGIN__MAX_FAILS_PER_ACCOUNT", "7") + t.Setenv("APPADMIN__SECURITY__LOGIN__ACCOUNT_WINDOW_SECONDS", "1200") + + var cfg Config + applyEnvOverrides(&cfg) + + if got := cfg.Session.DeviceBindingMode(); got != "id" { + t.Fatalf("session.device_binding mode = %q, want id", got) + } + if got := cfg.Session.IPBindingMode(); got != "exact" { + t.Fatalf("session.ip_binding mode = %q, want exact", got) + } + if cfg.Session.IPv4Prefix != 20 || cfg.Session.IPv6Prefix != 56 { + t.Fatalf("session prefixes: %+v", cfg.Session) + } + if len(cfg.Security.TrustedProxies) != 2 || + cfg.Security.TrustedProxies[0] != "172.16.0.0/12" || cfg.Security.TrustedProxies[1] != "10.1.2.3" { + t.Fatalf("trusted proxies: %+v", cfg.Security.TrustedProxies) + } + if cfg.Security.Login.MaxFailsPerIP != 3 || cfg.Security.Login.IPWindowSeconds != 30 || + cfg.Security.Login.MaxFailsPerAccount != 7 || cfg.Security.Login.AccountWindowSeconds != 1200 { + t.Fatalf("login limits: %+v", cfg.Security.Login) + } +} + +// Unknown binding modes and out-of-range prefixes fail loud; zero prefixes are +// normalized to the documented defaults. +func TestValidateSessionPolicy(t *testing.T) { + valid := func() *Config { + return &Config{RDBMS: RDBMSConfig{URL: "x"}, Seed: SeedConfig{Username: "a", Password: "b"}} + } + + cfg := valid() + if err := Validate(cfg); err != nil { + t.Fatalf("empty binding config rejected: %v", err) + } + if cfg.Session.IPv4Prefix != 24 || cfg.Session.IPv6Prefix != 64 { + t.Fatalf("zero prefixes must normalize to 24/64: %+v", cfg.Session) + } + if got := cfg.Session.DeviceBindingMode(); got != "off" { + t.Fatalf("empty device_binding must mean off, got %q", got) + } + if got := cfg.Session.IPBindingMode(); got != "off" { + t.Fatalf("empty ip_binding must mean off, got %q", got) + } + + for _, binding := range []string{"", "off", "id", "strict"} { + cfg = valid() + cfg.Session.DeviceBinding = binding + if err := Validate(cfg); err != nil { + t.Fatalf("device_binding %q rejected: %v", binding, err) + } + } + cfg = valid() + cfg.Session.DeviceBinding = "fingerprint" + if err := Validate(cfg); err == nil { + t.Fatal("unknown device_binding must be rejected") + } + + for _, binding := range []string{"", "off", "subnet", "exact"} { + cfg = valid() + cfg.Session.IPBinding = binding + if err := Validate(cfg); err != nil { + t.Fatalf("ip_binding %q rejected: %v", binding, err) + } + } + cfg = valid() + cfg.Session.IPBinding = "prefix" + if err := Validate(cfg); err == nil { + t.Fatal("unknown ip_binding must be rejected") + } + + cfg = valid() + cfg.Session.IPv4Prefix = 33 + if err := Validate(cfg); err == nil { + t.Fatal("ipv4_prefix 33 must be rejected") + } + cfg = valid() + cfg.Session.IPv6Prefix = 129 + if err := Validate(cfg); err == nil { + t.Fatal("ipv6_prefix 129 must be rejected") + } + cfg = valid() + cfg.Session.IPv4Prefix, cfg.Session.IPv6Prefix = 20, 56 + if err := Validate(cfg); err != nil { + t.Fatalf("custom prefixes rejected: %v", err) + } + + cfg = valid() + cfg.Security.TrustedProxies = []string{"not-an-ip"} + if err := Validate(cfg); err == nil { + t.Fatal("invalid trusted_proxies entry must be rejected") + } + cfg = valid() + cfg.Security.TrustedProxies = []string{"172.16.0.0/12", "10.1.2.3"} + if err := Validate(cfg); err != nil { + t.Fatalf("valid trusted proxies rejected: %v", err) + } +} + +// Refund-review wiring is all-or-nothing: base URL + ops key must both be set; +// half wiring is cleared (workbench stays hidden) and reported to main. +func TestNormalizePayOps(t *testing.T) { + both := &Config{} + both.Pay.Ops.BaseURL = "http://apisix:9080" + both.Pay.Ops.Key = "k" + if NormalizePayOps(both) { + t.Fatal("fully wired pay ops must survive normalization") + } + if both.Pay.Ops.BaseURL == "" || both.Pay.Ops.Key == "" { + t.Fatalf("fully wired pay ops must not be cleared: %+v", both.Pay.Ops) + } + + loneURL := &Config{} + loneURL.Pay.Ops.BaseURL = "http://apisix:9080" + if !NormalizePayOps(loneURL) { + t.Fatal("lone base url must be reported as disabled") + } + if loneURL.Pay.Ops.BaseURL != "" || loneURL.Pay.Ops.Key != "" { + t.Fatalf("half wiring must be cleared: %+v", loneURL.Pay.Ops) + } + + loneKey := &Config{} + loneKey.Pay.Ops.Key = "k" + if !NormalizePayOps(loneKey) { + t.Fatal("lone ops key must be reported as disabled") + } + + none := &Config{} + if NormalizePayOps(none) { + t.Fatal("both empty is intentionally disabled, not a mismatch") + } +} diff --git a/app-go/app-admin/internal/config/default_yaml_test.go b/app-go/app-admin/internal/config/default_yaml_test.go new file mode 100644 index 00000000..690dc522 --- /dev/null +++ b/app-go/app-admin/internal/config/default_yaml_test.go @@ -0,0 +1,57 @@ +package config + +// The embedded default.yaml is the source of truth for every non-secret knob; +// this guard catches a malformed YAML edit (main only fails at startup) and +// pins the payment-operations defaults added by the app-pay-admin merge. + +import ( + "os" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestDefaultYAMLParsesAndCarriesPayDefaults(t *testing.T) { + raw, err := os.ReadFile("../../default.yaml") + if err != nil { + t.Fatalf("read default.yaml: %v", err) + } + var cfg Config + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("default.yaml is not valid YAML: %v", err) + } + + if cfg.Server.Port != 8016 || cfg.Timezone != "Asia/Shanghai" { + t.Fatalf("baseline defaults changed: port=%d tz=%q", cfg.Server.Port, cfg.Timezone) + } + if cfg.PayRDBMS.MaxOpenConns != 10 || cfg.PayRDBMS.MaxIdleConns != 5 || cfg.PayRDBMS.ConnMaxLifetime != 1800 { + t.Fatalf("pay_rdbms defaults: %+v", cfg.PayRDBMS) + } + if cfg.PayRDBMS.URL != "" { + t.Fatalf("pay_rdbms.url must default to empty (pay disabled): %q", cfg.PayRDBMS.URL) + } + if cfg.Pay.Grant.DailyMaxCount != 50 || cfg.Pay.Grant.DailyMaxDays != 1000 { + t.Fatalf("pay.grant defaults: %+v", cfg.Pay.Grant) + } + if cfg.Pay.Ops.BaseURL != "" || cfg.Pay.Ops.Key != "" || !cfg.Pay.Ops.RefundReviewEnabled || + cfg.Pay.Ops.TimeoutSeconds != 5 { + t.Fatalf("pay.ops defaults: %+v", cfg.Pay.Ops) + } + if cfg.Pay.AuditFile != "/app/logs/pay-audit.jsonl" { + t.Fatalf("pay.audit_file default: %q", cfg.Pay.AuditFile) + } + // Session binding defaults: strict device fingerprint + subnet IP match, + // with the documented prefix sizes. + if cfg.Session.DeviceBinding != "strict" || cfg.Session.IPBinding != "subnet" || + cfg.Session.IPv4Prefix != 24 || cfg.Session.IPv6Prefix != 64 { + t.Fatalf("session binding defaults: %+v", cfg.Session) + } + // Login budget defaults: 10 fails / 60s per IP, 10 fails / 900s per account. + if cfg.Security.Login.MaxFailsPerIP != 10 || cfg.Security.Login.IPWindowSeconds != 60 || + cfg.Security.Login.MaxFailsPerAccount != 10 || cfg.Security.Login.AccountWindowSeconds != 900 { + t.Fatalf("security.login defaults: %+v", cfg.Security.Login) + } + if len(cfg.Security.TrustedProxies) != 0 { + t.Fatalf("trusted_proxies must default to empty (trust none): %+v", cfg.Security.TrustedProxies) + } +} diff --git a/app-go/app-admin/internal/db/db.go b/app-go/app-admin/internal/db/db.go index 4dc8152d..55284e96 100644 --- a/app-go/app-admin/internal/db/db.go +++ b/app-go/app-admin/internal/db/db.go @@ -1,12 +1,19 @@ -// Package db initializes the GORM MySQL connection against the SHARED bilirag -// database and applies app-admin's schema ownership. +// Package db initializes app-admin's TWO GORM MySQL connections: +// +// - DB — the SHARED bilirag database (app-auth / app-cloud schema); +// - PayDB — the app_pay database on app-pay-mysql (Java-owned schema), used +// only by the payment-operations console. It is optional: when +// pay_rdbms.url is unset or unreachable, main() leaves PayDB nil and every +// /api/pay/** route answers 503 while the rest of the console keeps working. // // Migration policy: create the three admin_* OWN tables if they do not exist; // never touch anything else. cloud_shares / cloud_files / cloud_folders / -// users / rbac_* tables are owned by app-cloud / app-auth (live data) — this -// service must NEVER AutoMigrate or ALTER them (guard test pins schemaDDL). +// users / rbac_* tables are owned by app-cloud / app-auth (live data), and the +// pay_* tables are owned by app-pay (Flyway) — this service must NEVER +// AutoMigrate or ALTER either of them (guard test pins schemaDDL). // The only write onto shared tables is the four moderation columns on -// cloud_shares via UPDATE (plan/1.0.16). +// cloud_shares via UPDATE; the only pay-domain writes are the +// controlled membership grant (pay_membership + pay_membership_event). package db import ( @@ -20,61 +27,172 @@ import ( "gorm.io/gorm" ) -var DB *gorm.DB +var ( + // DB is the shared bilirag connection (main console + admin_* own tables). + DB *gorm.DB + // PayDB is the app_pay connection (payment console). nil = pay module + // disabled: /api/pay/** answers 503 and /health reports pay=disabled. + PayDB *gorm.DB +) -// Init opens MySQL. dsn may be SQLAlchemy-style (mysql+aiomysql://user:pass@host:port/db). +// Init opens the shared bilirag MySQL. dsn may be SQLAlchemy-style +// (mysql+aiomysql://user:pass@host:port/db). func Init(dsn string, maxOpen, maxIdle, maxLife int, debug bool) error { + d, err := open(dsn, maxOpen, maxIdle, maxLife, debug) + if err != nil { + return err + } + DB = d + return nil +} + +// PayInit opens the SECOND connection: app_pay on the app-pay-mysql instance. +// +// The schema belongs to app-pay (Java Flyway); this package never migrates it. +// The connection is pinged once so "initialized" really means reachable: main() +// then degrades to pay disabled instead of failing the whole console, exactly +// the contract's "PayDB 初始化失败 -> /api/pay/** 503" behavior. +func PayInit(dsn string, maxOpen, maxIdle, maxLife int, debug bool) error { + d, err := open(dsn, maxOpen, maxIdle, maxLife, debug) + if err != nil { + return err + } + sqlDB, err := d.DB() + if err != nil { + return fmt.Errorf("get pay *sql.DB: %w", err) + } + if err := sqlDB.Ping(); err != nil { + _ = sqlDB.Close() + return fmt.Errorf("ping pay mysql: %w", err) + } + PayDB = d + return nil +} + +// open is the shared GORM MySQL opener used by both connections. +func open(dsn string, maxOpen, maxIdle, maxLife int, debug bool) (*gorm.DB, error) { gormDSN := toGormDSN(dsn) d, err := gorm.Open(mysql.Open(gormDSN), &gorm.Config{Logger: logger.NewGORMLogger(debug)}) if err != nil { - return fmt.Errorf("open mysql: %w", err) + return nil, fmt.Errorf("open mysql: %w", err) } sqlDB, err := d.DB() if err != nil { - return fmt.Errorf("get *sql.DB: %w", err) + return nil, fmt.Errorf("get *sql.DB: %w", err) } sqlDB.SetMaxOpenConns(maxOpen) sqlDB.SetMaxIdleConns(maxIdle) sqlDB.SetConnMaxLifetime(time.Duration(maxLife) * time.Second) - DB = d - return nil + return d, nil } func Close() error { - if DB == nil { + return closeDB(DB) +} + +// PayClose closes the app_pay connection (graceful shutdown). +func PayClose() error { + return closeDB(PayDB) +} + +func closeDB(d *gorm.DB) error { + if d == nil { return nil } - sqlDB, err := DB.DB() + sqlDB, err := d.DB() if err != nil { return err } return sqlDB.Close() } -// Ping checks the DB connection is actually usable — /health must truthfully -// probe it (SELECT 1; failure -> 503), not just report that the process runs. +// Ping checks the main DB connection is actually usable — /health must +// truthfully probe it (SELECT 1; failure -> 503), not just report that the +// process runs. func Ping() error { - if DB == nil { + return pingDB(DB) +} + +// PayPing probes the app_pay connection (SELECT 1). PayAvailable()==false is +// reported separately: /health distinguishes "not configured" from "broken". +func PayPing() error { + return pingDB(PayDB) +} + +// PayAvailable reports whether the payment console has a live connection. +func PayAvailable() bool { return PayDB != nil } + +func pingDB(d *gorm.DB) error { + if d == nil { return fmt.Errorf("db not initialized") } var one int - if err := DB.Raw("SELECT 1").Scan(&one).Error; err != nil { + if err := d.Raw("SELECT 1").Scan(&one).Error; err != nil { return err } return nil } -// Migrate creates ONLY the admin-owned tables, CREATE TABLE IF NOT EXISTS. -// NEVER add an AutoMigrate here: GORM would silently ALTER the shared schema. +// Migrate creates ONLY the admin-owned tables, CREATE TABLE IF NOT EXISTS, +// then adds the columns introduced after the initial release to those same +// tables. NEVER add an AutoMigrate here: GORM would silently ALTER the shared +// schema. The pay_* tables are deliberately absent too — they belong to app-pay. func Migrate() error { for _, ddl := range schemaDDL() { if err := DB.Exec(ddl).Error; err != nil { return fmt.Errorf("exec ddl: %w", err) } } + return ensureSessionColumns() +} + +// ensureSessionColumns brings a pre-existing admin_session table up to date. +// CREATE TABLE IF NOT EXISTS does not touch an existing table, so a deployment +// running since before device binding existed would otherwise miss the column +// and every session write would fail. Only admin_session is altered. +func ensureSessionColumns() error { + exists, err := sessionColumnExists("device_hash") + if err != nil { + return fmt.Errorf("inspect admin_session columns: %w", err) + } + if exists { + return nil + } + ddl := "ALTER TABLE admin_session ADD COLUMN device_hash varchar(64) NOT NULL DEFAULT ''" + if DB.Dialector.Name() == "mysql" { + ddl += " AFTER user_agent" + } + if err := DB.Exec(ddl).Error; err != nil { + return fmt.Errorf("add admin_session.device_hash: %w", err) + } return nil } +// sessionColumnExists checks admin_session for one column. MySQL is asked +// through information_schema; the SQLite test database through PRAGMA. +func sessionColumnExists(column string) (bool, error) { + if DB.Dialector.Name() == "mysql" { + var n int64 + err := DB.Raw( + "SELECT COUNT(*) FROM information_schema.columns "+ + "WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?", + "admin_session", column).Scan(&n).Error + return n > 0, err + } + var cols []struct { + Name string `gorm:"column:name"` + } + if err := DB.Raw("PRAGMA table_info(admin_session)").Scan(&cols).Error; err != nil { + return false, err + } + for _, col := range cols { + if col.Name == column { + return true, nil + } + } + return false, nil +} + func schemaDDL() []string { return []string{ `create table if not exists admin_user ( @@ -94,6 +212,7 @@ func schemaDDL() []string { expires_at datetime not null, ip varchar(64) null, user_agent varchar(500) null, + device_hash varchar(64) not null default '', created_at datetime null, index ix_admin_session_user (admin_user_id), index ix_admin_session_expires (expires_at) diff --git a/app-go/app-admin/internal/db/db_test.go b/app-go/app-admin/internal/db/db_test.go index 2482bad4..7f3b3c4d 100644 --- a/app-go/app-admin/internal/db/db_test.go +++ b/app-go/app-admin/internal/db/db_test.go @@ -1,13 +1,16 @@ // Guard test: Migrate must ONLY create admin_user / admin_session / // admin_audit. The shared bilirag tables (cloud_*, users, rbac_*) belong to // app-cloud / app-auth — if this test ever fails, a shared-table DDL leaked -// into the admin plane and a live schema is at risk of drift (plan/1.0.16 +// into the admin plane and a live schema is at risk of drift ( // red line). package db import ( "strings" "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" ) func TestSchemaDDLOnlyContainsOwnTables(t *testing.T) { @@ -47,9 +50,25 @@ func TestSchemaDDLOnlyContainsOwnTables(t *testing.T) { t.Fatalf("own table %s missing from schemaDDL", name) } } + // admin_session must carry the device fingerprint column that binds a + // session to the device that created it. + sessionDDL := "" + for _, s := range ddl { + if strings.Contains(strings.ToLower(s), "exists admin_session") { + sessionDDL = s + } + } + if !strings.Contains(strings.ToLower(sessionDDL), "device_hash") { + t.Fatalf("admin_session DDL must declare device_hash: %s", sessionDDL) + } for _, forbidden := range []string{ "cloud_shares", "cloud_files", "cloud_folders", "users", "rbac_role", "rbac_user_role", "user_profile", + // app_pay tables belong to app-pay (Java Flyway): the console only maps + // them for reads + the controlled grant write. A DDL leak here would + // drift a money schema — pin it. + "pay_order", "pay_membership", "pay_membership_event", + "pay_callback_log", "pay_product", "pay_refund_approval", } { for _, s := range ddl { if strings.Contains(strings.ToLower(s), "exists "+forbidden) { @@ -59,6 +78,62 @@ func TestSchemaDDLOnlyContainsOwnTables(t *testing.T) { } } +// A deployment whose admin_session table predates device binding must be +// upgraded in place: ensureSessionColumns adds the column once and is idempotent. +func TestEnsureSessionColumnsAddsDeviceHash(t *testing.T) { + prev := DB + gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlDB, err := gdb.DB() + if err != nil { + t.Fatalf("sqlite handle: %v", err) + } + sqlDB.SetMaxOpenConns(1) + DB = gdb + t.Cleanup(func() { DB = prev }) + + // Legacy table without the fingerprint column. + if err := DB.Exec(`create table admin_session ( + session_token varchar(128) not null primary key, + admin_user_id int not null, + expires_at datetime not null, + ip varchar(64) null, + user_agent varchar(500) null, + created_at datetime null + )`).Error; err != nil { + t.Fatalf("create legacy table: %v", err) + } + if exists, err := sessionColumnExists("device_hash"); err != nil || exists { + t.Fatalf("column must be missing before migration: exists=%v err=%v", exists, err) + } + if err := ensureSessionColumns(); err != nil { + t.Fatalf("add device_hash: %v", err) + } + if exists, err := sessionColumnExists("device_hash"); err != nil || !exists { + t.Fatalf("device_hash must exist after migration: exists=%v err=%v", exists, err) + } + // Existing rows get the empty default, so they can be recognized as + // pre-binding sessions and rejected. + if err := DB.Exec("insert into admin_session (session_token, admin_user_id, expires_at) values (?, ?, datetime('now'))", + "legacy-token", 1).Error; err != nil { + t.Fatalf("insert legacy row: %v", err) + } + var deviceHash string + if err := DB.Raw("select device_hash from admin_session where session_token = ?", "legacy-token"). + Scan(&deviceHash).Error; err != nil { + t.Fatalf("read device_hash: %v", err) + } + if deviceHash != "" { + t.Fatalf("legacy rows must default to an empty device hash, got %q", deviceHash) + } + // Running it again must be a no-op (no duplicate-column error). + if err := ensureSessionColumns(); err != nil { + t.Fatalf("ensureSessionColumns must be idempotent: %v", err) + } +} + func TestToGormDSN(t *testing.T) { cases := []struct{ in, want string }{ { @@ -80,3 +155,22 @@ func TestToGormDSN(t *testing.T) { } } } + +// PayInit/PayAvailable are the payment module's availability switch: the +// console must degrade to 503 instead of crashing when app_pay is absent, so +// the nil/initialized states are pinned here. +func TestPayConnectionAvailability(t *testing.T) { + prev := PayDB + PayDB = nil + defer func() { PayDB = prev }() + + if PayAvailable() { + t.Fatal("PayAvailable must be false when the pay connection is not initialized") + } + if err := PayPing(); err == nil { + t.Fatal("PayPing must fail when the pay connection is not initialized") + } + if err := PayClose(); err != nil { + t.Fatalf("PayClose on an uninitialized connection must be a no-op: %v", err) + } +} diff --git a/app-go/app-admin/internal/dto/console_user.go b/app-go/app-admin/internal/dto/console_user.go new file mode 100644 index 00000000..79d2f84b --- /dev/null +++ b/app-go/app-admin/internal/dto/console_user.go @@ -0,0 +1,29 @@ +package dto + +// Console account management wire contract: the console +// accounts live in admin_user (the SAME table the login/session flow uses), +// independent of the payment database. +// +// Nullable timestamps stay pointers so "never logged in" serialises as null. + +// ConsoleUserItem is one GET /api/console/users row. +type ConsoleUserItem struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + MustChangePassword bool `json:"mustChangePassword"` + LastLoginAt *string `json:"lastLoginAt"` + CreatedAt *string `json:"createdAt"` +} + +// ConsoleUserList is the GET /api/console/users body (username ASC). +type ConsoleUserList struct { + Items []ConsoleUserItem `json:"items"` +} + +// ConsoleUserCreated is the POST /api/console/users success body. +type ConsoleUserCreated struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` +} diff --git a/app-go/app-admin/internal/dto/dto.go b/app-go/app-admin/internal/dto/dto.go index 24d36e3d..d62a51f6 100644 --- a/app-go/app-admin/internal/dto/dto.go +++ b/app-go/app-admin/internal/dto/dto.go @@ -1,5 +1,5 @@ // Package dto defines the admin-plane JSON response DTOs (wire contract, -// plan/1.0.16 section 5). +// docs/app-admin.md). // // It is deliberately a leaf package: no imports from repo/service/router, no // business logic — only the exact JSON shapes the contract pins. The service diff --git a/app-go/app-admin/internal/dto/pay.go b/app-go/app-admin/internal/dto/pay.go new file mode 100644 index 00000000..faf8ed16 --- /dev/null +++ b/app-go/app-admin/internal/dto/pay.go @@ -0,0 +1,165 @@ +package dto + +// Payment-operations wire contract. +// +// Two rules from the contract are encoded in the types below: +// - payment uid is ALWAYS a JSON string (bigint precision safety); +// - money is integer cents (amountCents / priceCents), never floats; +// - nullable columns stay pointers so they serialise as null, not "". + +// PayOrderItem is one order row / the order detail body. +type PayOrderItem struct { + OrderNo string `json:"orderNo"` + UID string `json:"uid"` + SkuCode string `json:"skuCode"` + ProductTitle string `json:"productTitle"` + DurationDays int `json:"durationDays"` + AmountCents int64 `json:"amountCents"` + Channel string `json:"channel"` + Status string `json:"status"` + Version int `json:"version"` + ChannelTradeNo *string `json:"channelTradeNo"` + IdempotencyKey *string `json:"idempotencyKey"` + FailReason *string `json:"failReason"` + ExpiresAt string `json:"expiresAt"` + PaidAt *string `json:"paidAt"` + ClosedAt *string `json:"closedAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// PayOrderPage is the GET /api/pay/orders envelope. +type PayOrderPage struct { + Items []PayOrderItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// PayOrderDetail is the GET /api/pay/orders/:orderNo body. +type PayOrderDetail struct { + Order PayOrderItem `json:"order"` +} + +// PayCallbackItem is one channel-callback ledger row. +type PayCallbackItem struct { + OrderNo string `json:"orderNo"` + Channel string `json:"channel"` + PayloadMasked *string `json:"payloadMasked"` + SignatureResult string `json:"signatureResult"` + HandleResult string `json:"handleResult"` + Message *string `json:"message"` + CreatedAt string `json:"createdAt"` +} + +// PayCallbackPage is the GET /api/pay/orders/:orderNo/callbacks envelope. +type PayCallbackPage struct { + Items []PayCallbackItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// PayMembershipItem is one membership row. Active is the lazy-expiry flag +// (expireAt > now), same semantics as app-pay's isActive. +type PayMembershipItem struct { + UID string `json:"uid"` + Tier string `json:"tier"` + Active bool `json:"active"` + ExpireAt string `json:"expireAt"` + AutoRenew bool `json:"autoRenew"` + LastOrderNo *string `json:"lastOrderNo"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// PayMembershipLookup is the uid-exact GET /api/pay/memberships?uid= body: +// a missing row is null, never a 404. +type PayMembershipLookup struct { + Membership *PayMembershipItem `json:"membership"` +} + +// PayMembershipPage is the unfiltered GET /api/pay/memberships envelope. +type PayMembershipPage struct { + Items []PayMembershipItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// PayMembershipEventItem is one entitlement-audit row. +type PayMembershipEventItem struct { + ID int64 `json:"id"` + UID string `json:"uid"` + Type string `json:"type"` + OrderNo *string `json:"orderNo"` + Days int `json:"days"` + ExpireBefore string `json:"expireBefore"` + ExpireAfter string `json:"expireAfter"` + Reason *string `json:"reason"` + CreatedAt string `json:"createdAt"` +} + +// PayMembershipEventPage is the per-member / global event envelope. +type PayMembershipEventPage struct { + Items []PayMembershipEventItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// PayProductItem is one SKU row (read-only catalog). +type PayProductItem struct { + Code string `json:"code"` + Title string `json:"title"` + DurationDays int `json:"durationDays"` + PriceCents int64 `json:"priceCents"` + Status string `json:"status"` + Sort int `json:"sort"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// PayProductList is the GET /api/pay/products body. +type PayProductList struct { + Items []PayProductItem `json:"items"` +} + +// PayGrantResult is the POST /api/pay/grants success body. +type PayGrantResult struct { + UID string `json:"uid"` + Tier string `json:"tier"` + Days int `json:"days"` + ExpireBefore string `json:"expireBefore"` + ExpireAfter string `json:"expireAfter"` + Created bool `json:"created"` + Active bool `json:"active"` +} + +// PayRefundApprovalItem is one maker-checker approval row. +type PayRefundApprovalItem struct { + OrderNo string `json:"orderNo"` + Maker string `json:"maker"` + MakerReason string `json:"makerReason"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + DecidedAt *string `json:"decidedAt"` + Checker *string `json:"checker"` + CheckReason *string `json:"checkReason"` +} + +// PayRefundApprovalPage is the GET /api/pay/refunds envelope. +type PayRefundApprovalPage struct { + Items []PayRefundApprovalItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// PayRefundActionResult is the approve/reject success body. +type PayRefundActionResult struct { + OK bool `json:"ok"` + OrderNo string `json:"orderNo"` + Confirmed bool `json:"confirmed"` + Checker string `json:"checker"` +} diff --git a/app-go/app-admin/internal/model/model.go b/app-go/app-admin/internal/model/model.go index d00e63d1..0849788e 100644 --- a/app-go/app-admin/internal/model/model.go +++ b/app-go/app-admin/internal/model/model.go @@ -7,7 +7,7 @@ // rbac_user_role / user_profile are owned by app-cloud / app-auth - // plain row mappings here, NEVER migrated, never ALTERed. The only // write into shared tables is the four moderation columns on -// cloud_shares via UPDATE (plan/1.0.16 section 3.1 / 5). +// cloud_shares via UPDATE. // - No MinIO / object storage dependency at all: only name / size // metadata ever leaves the DB (admin must not see user file contents). package model @@ -35,12 +35,16 @@ func (AdminUser) TableName() string { return "admin_user" } // AdminSession is the DB-backed login session (HttpOnly cookie admin_session, // sliding 12h expiry - touched on every authenticated request). type AdminSession struct { - SessionToken string `gorm:"column:session_token;type:varchar(128);primaryKey"` - AdminUserID int64 `gorm:"column:admin_user_id;not null;index"` - ExpiresAt time.Time `gorm:"column:expires_at;not null"` - IP *string `gorm:"column:ip;type:varchar(64)"` - UserAgent *string `gorm:"column:user_agent;type:varchar(500)"` - CreatedAt *time.Time `gorm:"column:created_at"` + SessionToken string `gorm:"column:session_token;type:varchar(128);primaryKey"` + AdminUserID int64 `gorm:"column:admin_user_id;not null;index"` + ExpiresAt time.Time `gorm:"column:expires_at;not null"` + IP *string `gorm:"column:ip;type:varchar(64)"` + UserAgent *string `gorm:"column:user_agent;type:varchar(500)"` + // DeviceHash is the server-side fingerprint of the login device (never the + // raw device id). Empty for sessions issued while device binding was off; + // such rows are rejected once the binding is enabled. + DeviceHash string `gorm:"column:device_hash;type:varchar(64);not null;default:''"` + CreatedAt *time.Time `gorm:"column:created_at"` } func (AdminSession) TableName() string { return "admin_session" } @@ -112,8 +116,8 @@ type RbacRole struct { func (RbacRole) TableName() string { return "rbac_role" } // CloudShare maps cloud_shares (owner = app-cloud, which also owns the -// target_type / folder_id / legal_ack / moderation_* columns from plan 1.0.16 -// section 3.1). app-admin only ever UPDATEs the four moderation columns. +// target_type / folder_id / legal_ack / moderation_* columns). +// app-admin only ever UPDATEs the four moderation columns. type CloudShare struct { ID int64 `gorm:"column:id;primaryKey"` ShareToken string `gorm:"column:share_token"` diff --git a/app-pay-admin/internal/model/model.go b/app-go/app-admin/internal/model/pay.go similarity index 77% rename from app-pay-admin/internal/model/model.go rename to app-go/app-admin/internal/model/pay.go index f7e3a66a..a8c28d75 100644 --- a/app-pay-admin/internal/model/model.go +++ b/app-go/app-admin/internal/model/pay.go @@ -1,18 +1,22 @@ -// Package model maps the app_pay tables owned by app-pay (Java, -// app-pay/src/main/resources/schema.sql) plus the admin's own account table. +// Package model: pay-domain row mappings for the SECOND GORM connection +// (app_pay on the app-pay-mysql instance) plus the console account tables. // -// pay_* models are plain row mappings: nullable columns are pointers, money is -// long cents, time is DATETIME(6) read with loc=Local (Asia/Shanghai, matching -// app-pay's pinned JVM timezone). They are deliberately EXCLUDED from -// db.Migrate — the Java service owns that schema. +// RED LINES (pinned by internal/db's guard test): +// - pay_* tables are owned by app-pay (Java, Flyway migrations). They are +// plain row mappings here: NEVER AutoMigrate / ALTER them, never add them +// to db.Migrate. The only writes app-admin performs are the controlled +// membership grant (pay_membership + pay_membership_event) documented in +// service.PayGrantService; everything else is read-only. +// - money is int64 cents, nullable columns are pointers, DATETIME is read +// with loc=Local (Asia/Shanghai, matching app-pay's pinned JVM timezone). package model import "time" -// PayOrder maps pay_order. Read-only for this service. +// PayOrder maps pay_order (read-only). // // State machine (Java PayOrder): CREATED -> PAID -> DELIVERED; -// CREATED -> CLOSED; CLOSED -> PAID (reopen); REFUNDING/REFUNDED reserved M4. +// CREATED -> CLOSED; CLOSED -> PAID (reopen); REFUNDING/REFUNDED for the refund flow. type PayOrder struct { ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"-"` OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex:uk_pay_order_no"` @@ -37,7 +41,7 @@ type PayOrder struct { func (PayOrder) TableName() string { return "pay_order" } // PayMembership maps pay_membership. Written only by the grant transaction -// (service.GrantService), which mirrors app-pay MembershipService.extend. +// (service.PayGrantService), which mirrors app-pay MembershipService.extend. type PayMembership struct { ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"-"` UID int64 `gorm:"column:uid;not null;uniqueIndex:uk_pay_membership_uid"` @@ -52,7 +56,7 @@ type PayMembership struct { func (PayMembership) TableName() string { return "pay_membership" } // PayMembershipEvent maps pay_membership_event — the structured entitlement -// audit stream (ACTIVATE / RENEW / ADMIN_GRANT / REFUND_REVOKE[M4]). +// audit stream (ACTIVATE / RENEW / ADMIN_GRANT / REFUND_REVOKE). type PayMembershipEvent struct { ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"-"` UID int64 `gorm:"column:uid;not null;index:idx_pay_membership_event_uid"` @@ -81,7 +85,8 @@ type PayCallbackLog struct { func (PayCallbackLog) TableName() string { return "pay_callback_log" } -// PayProduct maps pay_product (SKU catalog, read-only in phase 1). +// PayProduct maps pay_product (SKU catalog, read-only: price/status changes +// remain an app-pay-side operational task). type PayProduct struct { ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"-"` Code string `gorm:"column:code;type:varchar(50);not null;uniqueIndex:uk_pay_product_code"` @@ -96,21 +101,10 @@ type PayProduct struct { func (PayProduct) TableName() string { return "pay_product" } -// PayAdminUser is the admin console's own account store (bcrypt; seeded with a -// default admin on first boot). This is the ONLY table this service migrates. -type PayAdminUser struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"-"` - Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex"` - PasswordHash string `gorm:"column:password_hash;type:varchar(100);not null" json:"-"` - Role string `gorm:"column:role;type:varchar(20);not null;default:viewer"` // admin / viewer - CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime"` -} - -func (PayAdminUser) TableName() string { return "payadmin_user" } - // PayRefundApproval maps pay_refund_approval (V4 migration): 退款双人复核请求 -// (maker-checker)。Read-only for this service —— approve/reject 经 app-pay 的 -// internal 端点执行(审计/状态机一致性),资金写不发生在本控制台。 +// (maker-checker)。Read-only here — approve/reject go through app-pay's +// internal endpoints so the fund write, its audit trail and the approval-table +// state machine all stay in one place. type PayRefundApproval struct { ID int64 `gorm:"column:id;primaryKey;autoIncrement"` OrderNo string `gorm:"column:order_no;type:varchar(64);not null"` diff --git a/app-pay-admin/internal/appops/client.go b/app-go/app-admin/internal/payops/client.go similarity index 63% rename from app-pay-admin/internal/appops/client.go rename to app-go/app-admin/internal/payops/client.go index 4d9032eb..01a98dec 100644 --- a/app-pay-admin/internal/appops/client.go +++ b/app-go/app-admin/internal/payops/client.go @@ -1,9 +1,11 @@ -// Package appops: app-pay internal 端点的 HTTP 客户端(退款复核工作台专用)。 +// Package payops: HTTP client for app-pay's internal operations endpoints +// (退款复核工作台专用). Migrated from app-pay-admin/internal/appops when the +// standalone pay console was merged into app-admin. // // 请求经 APISIX key-auth(pay-ops consumer)→ 资金写只发生在 app-pay 端点 // (审计 JSONL / 状态机 / 审批表一致性都由 app-pay 保证),本客户端携带 // PAY_OPS_KEY 与控制台登录身份(operator)。错误体透传 {"detail": ...}。 -package appops +package payops import ( "bytes" @@ -16,44 +18,32 @@ import ( "time" ) +// defaultTimeoutSeconds mirrors the contract's default (timeout 默认 5s). +const defaultTimeoutSeconds = 5 + +// Client proxies the two refund-review decisions to app-pay. type Client struct { baseURL string opsKey string hc *http.Client } +// New builds the client; timeoutSeconds <= 0 falls back to 5s. func New(baseURL, opsKey string, timeoutSeconds int) *Client { if timeoutSeconds <= 0 { - timeoutSeconds = 5 + timeoutSeconds = defaultTimeoutSeconds } return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), - opsKey: opsKey, + baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + opsKey: strings.TrimSpace(opsKey), hc: &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second}, } } -// Enabled reports whether the refund-review workbench is usable -// (requires an ops key; the master switch is checked at the router). -func (c *Client) Enabled() bool { return c != nil && c.baseURL != "" && c.opsKey != "" } - -// RefundRequest mirrors app-pay's GET /internal/pay/refund-requests JSON row. -type RefundRequest struct { - OrderNo string `json:"order_no"` - Maker string `json:"maker"` - MakerReason string `json:"maker_reason"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - DecidedAt *string `json:"decided_at"` -} - -// ListRefundRequests proxies GET /internal/pay/refund-requests?status=... -func (c *Client) ListRefundRequests(ctx context.Context, status string) ([]RefundRequest, error) { - var rows []RefundRequest - if err := c.get(ctx, "/internal/pay/refund-requests?status="+status, &rows); err != nil { - return nil, err - } - return rows, nil +// Enabled reports whether the refund-review workbench is wired (base URL + +// ops key present; the master switch is checked by the router). +func (c *Client) Enabled() bool { + return c != nil && c.baseURL != "" && c.opsKey != "" } // ConfirmRefund proxies POST /internal/pay/orders/{orderNo}/refund with @@ -74,12 +64,15 @@ func (c *Client) RejectRefund(ctx context.Context, orderNo, operator string) err map[string]any{"operator": operator}) } -func (c *Client) get(ctx context.Context, path string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) - if err != nil { - return err +// ConfirmRefundOrReject dispatches approve/reject for the refund workbench. +// +// confirm=true -> POST refund (confirm=true, operator=checker) +// confirm=false -> POST refund/reject (operator=checker) +func (c *Client) ConfirmRefundOrReject(ctx context.Context, orderNo, operator, reason string, confirm bool) error { + if confirm { + return c.ConfirmRefund(ctx, orderNo, operator, reason) } - return c.do(req, out) + return c.RejectRefund(ctx, orderNo, operator) } func (c *Client) post(ctx context.Context, path string, body any) error { @@ -95,6 +88,8 @@ func (c *Client) post(ctx context.Context, path string, body any) error { return c.do(req, nil) } +// do sends the request with the ops key and maps a non-2xx answer onto a +// descriptive error carrying app-pay's {"detail": ...} verbatim. func (c *Client) do(req *http.Request, out any) error { req.Header.Set("apikey", c.opsKey) resp, err := c.hc.Do(req) diff --git a/app-go/app-admin/internal/payops/client_test.go b/app-go/app-admin/internal/payops/client_test.go new file mode 100644 index 00000000..4c895f45 --- /dev/null +++ b/app-go/app-admin/internal/payops/client_test.go @@ -0,0 +1,116 @@ +package payops + +// Refund-review client tests: the two decision endpoints, the ops-key header, +// body shape and the {"detail"} error passthrough (mapped to 502 upstream). + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +type capturedCall struct { + Path string + APIKey string + Body map[string]any +} + +func TestClientEnabled(t *testing.T) { + cases := []struct { + base, key string + want bool + }{ + {"http://apisix:9080", "k", true}, + {"", "k", false}, + {"http://apisix:9080", "", false}, + {"", "", false}, + } + for _, tc := range cases { + if got := New(tc.base, tc.key, 0).Enabled(); got != tc.want { + t.Fatalf("New(%q,%q).Enabled() = %v, want %v", tc.base, tc.key, got, tc.want) + } + } + if (*Client)(nil).Enabled() { + t.Fatal("nil client must not be enabled") + } +} + +func TestClientConfirmAndReject(t *testing.T) { + var mu sync.Mutex + var calls []capturedCall + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + calls = append(calls, capturedCall{Path: r.URL.Path, APIKey: r.Header.Get("apikey"), Body: body}) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(srv.Close) + + client := New(srv.URL+"/", "ops-key", 5) + ctx := context.Background() + if err := client.ConfirmRefund(ctx, "PO1", "admin", "复核通过"); err != nil { + t.Fatalf("confirm refund: %v", err) + } + if err := client.RejectRefund(ctx, "PO2", "admin"); err != nil { + t.Fatalf("reject refund: %v", err) + } + if err := client.ConfirmRefundOrReject(ctx, "PO3", "admin", "why", true); err != nil { + t.Fatalf("dispatch approve: %v", err) + } + if err := client.ConfirmRefundOrReject(ctx, "PO4", "admin", "why", false); err != nil { + t.Fatalf("dispatch reject: %v", err) + } + + mu.Lock() + defer mu.Unlock() + wantPaths := []string{ + "/internal/pay/orders/PO1/refund", + "/internal/pay/orders/PO2/refund/reject", + "/internal/pay/orders/PO3/refund", + "/internal/pay/orders/PO4/refund/reject", + } + if len(calls) != len(wantPaths) { + t.Fatalf("expected %d calls, got %d: %+v", len(wantPaths), len(calls), calls) + } + for i, want := range wantPaths { + if calls[i].Path != want { + t.Fatalf("call %d path = %q, want %q", i, calls[i].Path, want) + } + if calls[i].APIKey != "ops-key" { + t.Fatalf("call %d missing ops key: %q", i, calls[i].APIKey) + } + if calls[i].Body["operator"] != "admin" { + t.Fatalf("call %d operator = %v", i, calls[i].Body["operator"]) + } + } + if calls[0].Body["confirm"] != true || calls[0].Body["reason"] != "复核通过" { + t.Fatalf("approve body: %+v", calls[0].Body) + } + if _, ok := calls[1].Body["confirm"]; ok { + t.Fatalf("reject body must not carry confirm: %+v", calls[1].Body) + } +} + +func TestClientErrorPassthrough(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"detail":"渠道退款失败"}`)) + })) + t.Cleanup(srv.Close) + + err := New(srv.URL, "ops-key", 5).ConfirmRefund(context.Background(), "PO1", "admin", "") + if err == nil { + t.Fatal("upstream failure must return an error") + } + if !strings.Contains(err.Error(), "500") || !strings.Contains(err.Error(), "渠道退款失败") { + t.Fatalf("error must carry status + app-pay detail: %v", err) + } +} diff --git a/app-go/app-admin/internal/repo/admin_user.go b/app-go/app-admin/internal/repo/admin_user.go index 179ad21c..9c06c185 100644 --- a/app-go/app-admin/internal/repo/admin_user.go +++ b/app-go/app-admin/internal/repo/admin_user.go @@ -139,3 +139,86 @@ func containsUniqueViolation(err error) bool { msg := err.Error() return strings.Contains(msg, "Duplicate entry") || strings.Contains(msg, "UNIQUE constraint failed") } + +// ── console account management (admin-only /api/console/users) ─────────── + +// ListAdminUsers returns every console account ordered by username (the +// /api/console/users contract: username ASC). +func ListAdminUsers() ([]model.AdminUser, error) { + var users []model.AdminUser + if err := db.DB.Order("username ASC").Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + +// CreateAdminUser inserts a new console account. New accounts must change the +// password on first login, exactly like the seeded admin. Duplicate usernames +// surface as ErrUserExists (409 at the HTTP boundary). +func CreateAdminUser(username, password, role string) (*model.AdminUser, error) { + hash, err := hashPassword(password) + if err != nil { + return nil, err + } + now := time.Now() + u := &model.AdminUser{ + Username: username, + PasswordHash: hash, + Role: role, + MustChangePassword: true, + CreatedAt: &now, + UpdatedAt: &now, + } + if err := db.DB.Create(u).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) || containsUniqueViolation(err) { + return nil, ErrUserExists + } + return nil, err + } + return u, nil +} + +// ResetUserPassword sets a new password AND forces a password change on the +// next login (admin-initiated reset). Sessions are invalidated by the caller +// (repo.DeleteUserSessions) so a stolen cookie cannot survive a reset. +func ResetUserPassword(id int64, password string) error { + if len(password) < 8 { + return ErrWeakPassword + } + hash, err := hashPassword(password) + if err != nil { + return err + } + res := db.DB.Model(&model.AdminUser{}).Where("id = ?", id).Updates(map[string]any{ + "password_hash": hash, + "must_change_password": true, + "updated_at": time.Now(), + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrUserNotFound + } + return nil +} + +// DeleteAdminUser removes one console account (sessions are deleted first by +// the service so no orphan session can resolve a deleted user). +func DeleteAdminUser(id int64) error { + res := db.DB.Delete(&model.AdminUser{}, id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrUserNotFound + } + return nil +} + +// CountAdminUsersByRole reports how many accounts carry role (last-admin guard). +func CountAdminUsersByRole(role string) (int64, error) { + var n int64 + err := db.DB.Model(&model.AdminUser{}).Where("role = ?", role).Count(&n).Error + return n, err +} diff --git a/app-go/app-admin/internal/repo/audit.go b/app-go/app-admin/internal/repo/audit.go index 4ef2a4b5..a61919e9 100644 --- a/app-go/app-admin/internal/repo/audit.go +++ b/app-go/app-admin/internal/repo/audit.go @@ -4,6 +4,7 @@ package repo import ( + "errors" "time" "app-admin/internal/db" @@ -13,6 +14,9 @@ import ( // RecordAudit appends one ledger row. Failures surface to the caller instead // of being swallowed: an audit gap on a moderation write is unacceptable. func RecordAudit(adminName, action, targetType, targetID, detail string) error { + if db.DB == nil { + return errors.New("admin database not initialized") + } a := &model.AdminAudit{ AdminName: adminName, Action: action, @@ -24,6 +28,21 @@ func RecordAudit(adminName, action, targetType, targetID, detail string) error { return db.DB.Create(a).Error } +// HasAuditDetail reports whether the ledger already holds an entry for +// (adminName, action) whose detail contains substring. The login flow uses it +// to tell a repeat device / address from a first sighting. Callers pass literal +// fragments (no wildcard characters), so no LIKE escaping is needed. +func HasAuditDetail(adminName, action, substring string) (bool, error) { + if db.DB == nil { + return false, errors.New("admin database not initialized") + } + var n int64 + err := db.DB.Model(&model.AdminAudit{}). + Where("admin_name = ? AND action = ? AND detail LIKE ?", adminName, action, "%"+substring+"%"). + Count(&n).Error + return n > 0, err +} + // AuditItem is one row for the /api/audit list. type AuditItem struct { ID int64 diff --git a/app-go/app-admin/internal/repo/moderation.go b/app-go/app-admin/internal/repo/moderation.go index 321e6468..5fbcceeb 100644 --- a/app-go/app-admin/internal/repo/moderation.go +++ b/app-go/app-admin/internal/repo/moderation.go @@ -2,7 +2,7 @@ // moderation columns of cloud_shares (moderation_status / moderation_reason / // moderation_time / moderation_by) and append the admin_audit row in the SAME // transaction. is_revoked is deliberately NEVER touched here — owner-revoke -// semantics belong to app-cloud (plan/1.0.16 section 5). +// semantics belong to app-cloud. package repo import ( diff --git a/app-pay-admin/internal/repo/membership.go b/app-go/app-admin/internal/repo/pay_membership.go similarity index 81% rename from app-pay-admin/internal/repo/membership.go rename to app-go/app-admin/internal/repo/pay_membership.go index 07ef2e88..c1ffd0d5 100644 --- a/app-pay-admin/internal/repo/membership.go +++ b/app-go/app-admin/internal/repo/pay_membership.go @@ -1,16 +1,16 @@ -// Package repo: membership + entitlement-event queries over pay_membership / -// pay_membership_event. +// Package repo: membership + entitlement-event reads over db.PayDB +// (pay_membership / pay_membership_event). package repo import ( - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" + "app-admin/internal/db" + "app-admin/internal/model" ) // GetMembershipByUID resolves one membership by its unique uid (nil if absent). func GetMembershipByUID(uid int64) (*model.PayMembership, error) { var m model.PayMembership - err := db.DB.Where("uid = ?", uid).First(&m).Error + err := db.PayDB.Where("uid = ?", uid).First(&m).Error if isNotFound(err) { return nil, nil } @@ -21,10 +21,10 @@ func GetMembershipByUID(uid int64) (*model.PayMembership, error) { } // ListMemberships returns one page of memberships (newest first, PK order; -// only the uid unique index exists, so full listing relies on the PK scan — +// only the uid unique index exists, so a full listing relies on the PK scan — // admin-scale row counts keep this bounded). func ListMemberships(limit, offset int) ([]model.PayMembership, int64, error) { - q := db.DB.Model(&model.PayMembership{}) + q := db.PayDB.Model(&model.PayMembership{}) var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err @@ -49,7 +49,7 @@ type EventFilter struct { // ListEvents returns one page of entitlement events, newest first. func ListEvents(f EventFilter) ([]model.PayMembershipEvent, int64, error) { - q := db.DB.Model(&model.PayMembershipEvent{}) + q := db.PayDB.Model(&model.PayMembershipEvent{}) if f.UIDSet { q = q.Where("uid = ?", f.UID) } diff --git a/app-pay-admin/internal/repo/order.go b/app-go/app-admin/internal/repo/pay_order.go similarity index 80% rename from app-pay-admin/internal/repo/order.go rename to app-go/app-admin/internal/repo/pay_order.go index c20132df..caf13b11 100644 --- a/app-pay-admin/internal/repo/order.go +++ b/app-go/app-admin/internal/repo/pay_order.go @@ -1,16 +1,17 @@ -// Package repo: order + callback-log queries over app-pay's pay_order / -// pay_callback_log (read-only). Every list helper documents the index it hits -// (plan/1.0.7 §3.6 rule: a pay-domain query must be able to name its index). +// Package repo: pay-domain reads over the SECOND connection (db.PayDB, +// app_pay schema owned by app-pay). Every list helper documents the index it +// hits (rule: a pay-domain query must be able to name its +// index). Nothing here writes except the controlled grant in service. package repo import ( "time" - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" + "app-admin/internal/db" + "app-admin/internal/model" ) -// OrderFilter carries the admin list filters. Zero-value fields are ignored. +// OrderFilter carries the console list filters. Zero-value fields are ignored. type OrderFilter struct { UID int64 // idx_pay_order_uid_status (leftmost uid) UIDSet bool @@ -34,7 +35,7 @@ type OrderFilter struct { // auto-increment PK correlates with created_at and keeps the scan index-backed) // plus the total count matching the same predicates. func ListOrders(f OrderFilter) ([]model.PayOrder, int64, error) { - q := db.DB.Model(&model.PayOrder{}) + q := db.PayDB.Model(&model.PayOrder{}) if f.OrderNo != "" { q = q.Where("order_no = ?", f.OrderNo) } @@ -74,7 +75,7 @@ func ListOrders(f OrderFilter) ([]model.PayOrder, int64, error) { // GetOrderByNo resolves one order by its unique order number (nil if absent). func GetOrderByNo(orderNo string) (*model.PayOrder, error) { var o model.PayOrder - err := db.DB.Where("order_no = ?", orderNo).First(&o).Error + err := db.PayDB.Where("order_no = ?", orderNo).First(&o).Error if isNotFound(err) { return nil, nil } @@ -88,7 +89,7 @@ func GetOrderByNo(orderNo string) (*model.PayOrder, error) { // absent) — used to trace a callback/alipay trade back to the local order. func GetOrderByTradeNo(tradeNo string) (*model.PayOrder, error) { var o model.PayOrder - err := db.DB.Where("channel_trade_no = ?", tradeNo).First(&o).Error + err := db.PayDB.Where("channel_trade_no = ?", tradeNo).First(&o).Error if isNotFound(err) { return nil, nil } @@ -101,7 +102,7 @@ func GetOrderByTradeNo(tradeNo string) (*model.PayOrder, error) { // ListCallbacks returns the callback ledger rows for one order, newest first // (idx_pay_callback_order_no). func ListCallbacks(orderNo string, limit, offset int) ([]model.PayCallbackLog, int64, error) { - q := db.DB.Model(&model.PayCallbackLog{}).Where("order_no = ?", orderNo) + q := db.PayDB.Model(&model.PayCallbackLog{}).Where("order_no = ?", orderNo) var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err diff --git a/app-pay-admin/internal/repo/product.go b/app-go/app-admin/internal/repo/pay_product.go similarity index 60% rename from app-pay-admin/internal/repo/product.go rename to app-go/app-admin/internal/repo/pay_product.go index 9a82930d..1f50ab88 100644 --- a/app-pay-admin/internal/repo/product.go +++ b/app-go/app-admin/internal/repo/pay_product.go @@ -1,26 +1,28 @@ -// Package repo: SKU catalog reads (pay_product, read-only in phase 1) + -// shared helpers. +// Package repo: SKU catalog reads (pay_product, read-only) + the shared +// not-found normalizer for the pay-domain getters. package repo import ( "errors" - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" + "app-admin/internal/db" + "app-admin/internal/model" + "gorm.io/gorm" ) // ListProducts returns the SKU catalog ordered for display (sort ASC, then id). func ListProducts() ([]model.PayProduct, error) { var ps []model.PayProduct - err := db.DB.Order("sort ASC, id ASC").Find(&ps).Error + err := db.PayDB.Order("sort ASC, id ASC").Find(&ps).Error if err != nil { return nil, err } return ps, nil } -// isNotFound normalizes gorm's ErrRecordNotFound (used by the single-row getters). +// isNotFound normalizes gorm's ErrRecordNotFound (used by the single-row +// pay-domain getters). func isNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } diff --git a/app-pay-admin/internal/repo/refundapproval.go b/app-go/app-admin/internal/repo/pay_refund.go similarity index 88% rename from app-pay-admin/internal/repo/refundapproval.go rename to app-go/app-admin/internal/repo/pay_refund.go index 224c8c1a..c9c1f25a 100644 --- a/app-pay-admin/internal/repo/refundapproval.go +++ b/app-go/app-admin/internal/repo/pay_refund.go @@ -1,11 +1,11 @@ -// Package repo: 退款复核审批单读取(pay_refund_approval,V4 迁移)。 +// Package repo: 退款复核审批单读取(pay_refund_approval,app-pay V4 迁移)。 // 只读:approve/reject 的资金写经 app-pay 的 internal 端点执行, // 本服务不直接改这张表(审计/状态机一致性)。 package repo import ( - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" + "app-admin/internal/db" + "app-admin/internal/model" ) // RefundApprovalFilter selects approval rows. status 为空 = 全部; @@ -19,7 +19,7 @@ type RefundApprovalFilter struct { // ListRefundApprovals returns one page of approvals, newest id first // (单量 = 运营人工处理量级,id 倒序足够;status 走索引过滤)。 func ListRefundApprovals(f RefundApprovalFilter) ([]model.PayRefundApproval, int64, error) { - q := db.DB.Model(&model.PayRefundApproval{}) + q := db.PayDB.Model(&model.PayRefundApproval{}) if f.Status != "" { q = q.Where("status = ?", f.Status) } diff --git a/app-go/app-admin/internal/repo/session.go b/app-go/app-admin/internal/repo/session.go index 3462f25b..c621258a 100644 --- a/app-go/app-admin/internal/repo/session.go +++ b/app-go/app-admin/internal/repo/session.go @@ -20,14 +20,17 @@ type SessionBundle struct { User model.AdminUser } -// CreateSession persists a fresh login session. -func CreateSession(token string, userID int64, ip, userAgent string, expires time.Time) error { +// CreateSession persists a fresh login session. deviceHash is the opaque +// server-side device fingerprint (empty when device binding is off); the raw +// device id never reaches the database. +func CreateSession(token string, userID int64, ip, userAgent, deviceHash string, expires time.Time) error { s := &model.AdminSession{ SessionToken: token, AdminUserID: userID, ExpiresAt: expires, IP: strPtr(ip), UserAgent: strPtr(truncate(userAgent, 500)), + DeviceHash: deviceHash, } return db.DB.Create(s).Error } diff --git a/app-go/app-admin/internal/repo/shares_query.go b/app-go/app-admin/internal/repo/shares_query.go index dd7cf487..a473c7b8 100644 --- a/app-go/app-admin/internal/repo/shares_query.go +++ b/app-go/app-admin/internal/repo/shares_query.go @@ -2,7 +2,7 @@ // cloud_folders / user_profile for display metadata (name / size / counts / // owner nickname). METADATA ONLY — no object keys, no MinIO. Folder shares // aggregate their live subtree via recursive CTE with a hard 2000-item cap -// (plan/1.0.16 section 5). +// (app-admin API contract). package repo import ( diff --git a/app-go/app-admin/internal/router/auth.go b/app-go/app-admin/internal/router/auth.go index b22e116b..55d7d208 100644 --- a/app-go/app-admin/internal/router/auth.go +++ b/app-go/app-admin/internal/router/auth.go @@ -1,14 +1,19 @@ // Session gate + auth endpoints: cookie name admin_session (HttpOnly, // SameSite=Lax so the same-origin SPA and dev proxy both work), sliding 12h -// TTL enforced by the service, login throttle 10 failures / minute / IP. +// TTL enforced by the service, the device / IP binding checks re-run on every +// authenticated request, and the login throttle budgets held by the service. package router import ( + "crypto/subtle" "errors" + "net" "net/http" "strconv" + "strings" "time" + "app-admin/internal/config" "app-admin/internal/dto" "app-admin/internal/repo" "app-admin/internal/service" @@ -40,13 +45,7 @@ func identityOf(c *gin.Context) (adminIdentity, bool) { return id, ok } -// secureCookie decides whether the session cookie gets the Secure flag based -// on the transport actually seen by this process. -func secureCookie(c *gin.Context) bool { - return c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" -} - -// setSessionCookie / clearSessionCookie centralise cookie attributes. +// setSessionCookie centralises the cookie attributes. func setSessionCookie(c *gin.Context, token string, maxAge int, secure bool) { http.SetCookie(c.Writer, &http.Cookie{ Name: sessionCookie, @@ -59,7 +58,7 @@ func setSessionCookie(c *gin.Context, token string, maxAge int, secure bool) { }) } -func clearSessionCookie(c *gin.Context) { +func clearSessionCookie(c *gin.Context, secure bool) { http.SetCookie(c.Writer, &http.Cookie{ Name: sessionCookie, Value: "", @@ -67,12 +66,23 @@ func clearSessionCookie(c *gin.Context) { MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, - Secure: secureCookie(c), + Secure: secure, }) } -// sessionGate resolves the admin_session cookie to a live DB session and -// slides its expiry (sliding 12h). 401 with {"detail": ...} otherwise. +// clientIP resolves the real client address honouring the trusted-proxy list. +func (r *Router) clientIP(c *gin.Context) string { + return clientIP(c, r.trustedProxies) +} + +// sessionCookieSecure reports whether the cookie may carry the Secure flag. +func (r *Router) sessionCookieSecure(c *gin.Context) bool { + return secureCookie(c, r.trustedProxies) +} + +// sessionGate resolves the admin_session cookie to a live DB session, re-checks +// its device / IP binding and only then slides its expiry. 401 with +// {"detail": ...} otherwise. func (r *Router) sessionGate() gin.HandlerFunc { return func(c *gin.Context) { token, _ := c.Cookie(sessionCookie) @@ -92,6 +102,16 @@ func (r *Router) sessionGate() gin.HandlerFunc { c.Abort() return } + if !r.sessionBindingOK(c, token, bundle) { + return // rejection response already written + } + // Sliding expiry only after the binding checks pass, so a rejected + // request cannot keep a stolen session alive. + if err := r.auth.TouchSession(token); err != nil { + serverError(c, err, "touch session") + c.Abort() + return + } c.Set(ctxIdentity, adminIdentity{ UserID: bundle.User.ID, Username: bundle.User.Username, @@ -102,6 +122,71 @@ func (r *Router) sessionGate() gin.HandlerFunc { } } +// sessionBindingOK re-checks the presented session against the current request: +// device fingerprint (when enabled) and client address (off / exact / subnet). +// A mismatch means the cookie is being replayed from another device or network, +// so the row is deleted, the attempt audited and the caller told to log in +// again. Sessions created before device binding was enabled carry no hash and +// therefore always fail the device check after an upgrade. +func (r *Router) sessionBindingOK(c *gin.Context, token string, bundle *repo.SessionBundle) bool { + ip := r.clientIP(c) + deviceMode := r.cfg.Session.DeviceBindingMode() + if deviceMode != "off" { + fingerprint, err := deviceFingerprint(c, deviceMode) + if err != nil || bundle.Session.DeviceHash == "" || + subtle.ConstantTimeCompare([]byte(bundle.Session.DeviceHash), []byte(fingerprint)) != 1 { + r.dropMismatchedSession(c, token, bundle, "session_device_mismatch", ip, fingerprint) + return false + } + } + ipMode := r.cfg.Session.IPBindingMode() + if ipMode != "off" && !sessionIPMatches(bundle.Session.IP, ip, ipMode, r.cfg.Session) { + r.dropMismatchedSession(c, token, bundle, "session_ip_mismatch", ip, "") + return false + } + return true +} + +// dropMismatchedSession invalidates the presented session and answers 401 in +// the shared error shape. +func (r *Router) dropMismatchedSession(c *gin.Context, token string, bundle *repo.SessionBundle, + action, ip, fingerprint string) { + _ = repo.DeleteSession(token) + detailText := "ip=" + truncateStr(ip, 60) + " device=" + devicePrefix(fingerprint) + _ = repo.RecordAudit(bundle.User.Username, action, "session", + strconv.FormatInt(bundle.User.ID, 10), detailText) + clearSessionCookie(c, r.sessionCookieSecure(c)) + detail(c, http.StatusUnauthorized, "login required") + c.Abort() +} + +// sessionIPMatches compares the stored session address with the current client +// address under the configured policy. An empty / unparsable stored value or a +// family mismatch fails closed. +func sessionIPMatches(stored *string, current, mode string, sessionCfg config.SessionConfig) bool { + if stored == nil || strings.TrimSpace(*stored) == "" { + return false + } + storedIP := net.ParseIP(strings.TrimSpace(*stored)) + currentIP := net.ParseIP(strings.TrimSpace(current)) + if storedIP == nil || currentIP == nil { + return false + } + if mode == "exact" { + return storedIP.Equal(currentIP) + } + storedV4, currentV4 := storedIP.To4(), currentIP.To4() + if storedV4 != nil || currentV4 != nil { + if storedV4 == nil || currentV4 == nil { + return false + } + mask := net.CIDRMask(sessionCfg.IPv4PrefixOr(), 32) + return storedV4.Mask(mask).Equal(currentV4.Mask(mask)) + } + mask := net.CIDRMask(sessionCfg.IPv6PrefixOr(), 128) + return storedIP.To16().Mask(mask).Equal(currentIP.To16().Mask(mask)) +} + // requireAdmin gates admin-only writes (ban / unban / approve). Password // change deliberately sits OUTSIDE this gate. func requireAdmin() gin.HandlerFunc { @@ -127,8 +212,15 @@ func (r *Router) apiLogin(c *gin.Context) { detail(c, http.StatusBadRequest, "json body with username/password required") return } - ip := c.ClientIP() - user, token, err := r.auth.Login(req.Username, req.Password, ip, c.Request.UserAgent()) + // Resolve the fingerprint before touching the throttle: a malformed device + // header is a client bug, not a credential guess. + fingerprint, err := deviceFingerprint(c, r.cfg.Session.DeviceBindingMode()) + if err != nil { + detail(c, http.StatusBadRequest, err.Error()) + return + } + ip := r.clientIP(c) + user, token, err := r.auth.Login(req.Username, req.Password, ip, c.Request.UserAgent(), fingerprint) switch { case errors.Is(err, service.ErrThrottled): c.Header("Retry-After", strconv.Itoa(int(time.Minute.Seconds()))) @@ -138,7 +230,7 @@ func (r *Router) apiLogin(c *gin.Context) { case err != nil: serverError(c, err, "login") default: - setSessionCookie(c, token, r.auth.TTLSeconds(), secureCookie(c)) + setSessionCookie(c, token, r.auth.TTLSeconds(), r.sessionCookieSecure(c)) c.JSON(http.StatusOK, dto.SessionInfo{ Username: user.Username, Role: user.Role, @@ -150,11 +242,11 @@ func (r *Router) apiLogin(c *gin.Context) { func (r *Router) apiLogout(c *gin.Context) { id, _ := identityOf(c) token, _ := c.Cookie(sessionCookie) - if err := r.auth.Logout(id.Username, token, c.ClientIP()); err != nil { + if err := r.auth.Logout(id.Username, token, r.clientIP(c)); err != nil { serverError(c, err, "logout") return } - clearSessionCookie(c) + clearSessionCookie(c, r.sessionCookieSecure(c)) c.JSON(http.StatusOK, gin.H{"ok": true}) } @@ -203,5 +295,13 @@ func (r *Router) apiChangePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } +// truncateStr bounds a value before it lands in a fixed-width audit column. +func truncateStr(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + // bcrypt stays referenced: hashing itself lives in the repo/service layers. var _ = bcrypt.DefaultCost diff --git a/app-go/app-admin/internal/router/console_users.go b/app-go/app-admin/internal/router/console_users.go new file mode 100644 index 00000000..11e4f2ea --- /dev/null +++ b/app-go/app-admin/internal/router/console_users.go @@ -0,0 +1,135 @@ +// Console account management endpoints: /api/console/users. +// +// Deliberately separate from GET /api/users (the read-only BUSINESS user +// lookup) — these accounts are the admin console's own admin_user rows. All +// four routes are admin-only; the module never touches the payment database, +// so it keeps working when app_pay is disabled. +package router + +import ( + "errors" + "net/http" + "strconv" + + "app-admin/internal/repo" + "app-admin/internal/service" + + "github.com/gin-gonic/gin" +) + +// registerConsoleUserRoutes mounts /api/console/users behind requireAdmin. +func (r *Router) registerConsoleUserRoutes(api *gin.RouterGroup) { + group := api.Group("/console/users", requireAdmin()) + group.GET("", r.apiConsoleListUsers) + group.POST("", r.apiConsoleCreateUser) + group.POST("/:id/password", r.apiConsoleResetPassword) + group.DELETE("/:id", r.apiConsoleDeleteUser) +} + +// apiConsoleListUsers GET /api/console/users — every account, username ASC. +func (r *Router) apiConsoleListUsers(c *gin.Context) { + result, err := r.console.List() + if err != nil { + serverError(c, err, "console users") + return + } + c.JSON(http.StatusOK, result) +} + +// apiConsoleCreateUser POST /api/console/users +// body {"username":2..64,"password":8..128,"role"?:"admin"|"viewer"}. +func (r *Router) apiConsoleCreateUser(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&req); err != nil { + detail(c, http.StatusBadRequest, "json body with username/password required") + return + } + actor := operatorOf(c) + result, err := r.console.Create(actor, req.Username, req.Password, req.Role) + if err != nil { + switch { + case errors.Is(err, repo.ErrUserExists): + detail(c, http.StatusConflict, "username already exists") + case errors.Is(err, service.ErrConsoleInvalidUsername), + errors.Is(err, service.ErrConsoleInvalidPassword), + errors.Is(err, service.ErrConsolePasswordTooLong), + errors.Is(err, service.ErrConsoleInvalidRole): + detail(c, http.StatusBadRequest, err.Error()) + default: + serverError(c, err, "console user create") + } + return + } + c.JSON(http.StatusOK, result) +} + +// apiConsoleResetPassword POST /api/console/users/:id/password +// body {"password":8..128}. Forces a password change on next login and drops +// every session of that account. +func (r *Router) apiConsoleResetPassword(c *gin.Context) { + id, ok := consoleUserID(c) + if !ok { + return + } + var req struct { + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + detail(c, http.StatusBadRequest, "json body with password required") + return + } + if err := r.console.ResetPassword(operatorOf(c), id, req.Password); err != nil { + switch { + case errors.Is(err, repo.ErrUserNotFound): + detail(c, http.StatusNotFound, "user not found") + case errors.Is(err, service.ErrConsoleInvalidPassword), + errors.Is(err, service.ErrConsolePasswordTooLong): + detail(c, http.StatusBadRequest, err.Error()) + default: + serverError(c, err, "console user password reset") + } + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// apiConsoleDeleteUser DELETE /api/console/users/:id — refuses self-deletion +// and deletion of the last admin; also drops the target's sessions. +func (r *Router) apiConsoleDeleteUser(c *gin.Context) { + id, ok := consoleUserID(c) + if !ok { + return + } + me, ok := identityOf(c) + if !ok { + detail(c, http.StatusUnauthorized, "login required") + return + } + if err := r.console.Delete(me.UserID, me.Username, id); err != nil { + switch { + case errors.Is(err, repo.ErrUserNotFound): + detail(c, http.StatusNotFound, "user not found") + case errors.Is(err, service.ErrCannotDeleteSelf), + errors.Is(err, service.ErrLastAdmin): + detail(c, http.StatusBadRequest, err.Error()) + default: + serverError(c, err, "console user delete") + } + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// consoleUserID parses the :id path segment (admin_user auto-increment id). +func consoleUserID(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + detail(c, http.StatusBadRequest, "invalid user id") + return 0, false + } + return id, true +} diff --git a/app-go/app-admin/internal/router/console_users_test.go b/app-go/app-admin/internal/router/console_users_test.go new file mode 100644 index 00000000..ea5e8ba6 --- /dev/null +++ b/app-go/app-admin/internal/router/console_users_test.go @@ -0,0 +1,198 @@ +package router + +// Console account management tests: admin-only /api/console/users +// lifecycle, validation, audit trail, forced password change + session +// invalidation, self-delete / last-admin guards, and independence from the +// payment database. + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "app-admin/internal/db" + "app-admin/internal/model" +) + +func TestConsoleUsersLifecycle(t *testing.T) { + // Payment DB deliberately disabled: account management must keep working. + env := newPayTestEnv(t, payTestOptions{payDisabled: true}) + + // List: the seeded admin only, username ASC, no secrets in the payload. + w := env.do(t, http.MethodGet, "/api/console/users", "") + if w.Code != http.StatusOK { + t.Fatalf("list console users: got %d, body %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "password") { + t.Fatalf("console user list must never expose password material: %s", w.Body.String()) + } + type userList struct { + Items []struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + MustChangePassword bool `json:"mustChangePassword"` + LastLoginAt *string `json:"lastLoginAt"` + CreatedAt *string `json:"createdAt"` + } `json:"items"` + } + var list userList + decodeJSON(t, w.Body.String(), &list) + if len(list.Items) != 1 || list.Items[0].Username != "admin" || list.Items[0].Role != "admin" || + !list.Items[0].MustChangePassword || list.Items[0].LastLoginAt == nil { + t.Fatalf("seeded admin list: %s", w.Body.String()) + } + adminID := list.Items[0].ID + + // Create a viewer (role defaults to viewer when omitted). + w = env.do(t, http.MethodPost, "/api/console/users", + `{"username":"ops-viewer","password":"viewer-pass-123"}`) + if w.Code != http.StatusOK { + t.Fatalf("create viewer: got %d, body %s", w.Code, w.Body.String()) + } + var created struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + } + decodeJSON(t, w.Body.String(), &created) + if created.Username != "ops-viewer" || created.Role != "viewer" || created.ID <= 0 { + t.Fatalf("create response: %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"role":"viewer"`) { + t.Fatalf("default role must be viewer: %s", w.Body.String()) + } + var row model.AdminUser + if err := db.DB.First(&row, created.ID).Error; err != nil { + t.Fatalf("load created account: %v", err) + } + if !row.MustChangePassword { + t.Fatal("console-created accounts must change password on first login") + } + + // Duplicate username -> 409. + if w := env.do(t, http.MethodPost, "/api/console/users", + `{"username":"ops-viewer","password":"viewer-pass-123"}`); w.Code != http.StatusConflict || + w.Body.String() != `{"detail":"username already exists"}` { + t.Fatalf("duplicate username: got %d %s", w.Code, w.Body.String()) + } + + // Validation errors -> 400. + for _, body := range []string{ + `{"username":"x","password":"viewer-pass-123"}`, // username too short + `{"username":"` + strings.Repeat("x", 65) + `","password":"viewer-pass-123"}`, // too long + `{"username":"ok-user","password":"short"}`, // password too short + `{"username":"ok-user","password":"` + strings.Repeat("x", 129) + `"}`, // too long + `{"username":"ok-user","password":"viewer-pass-123","role":"superuser"}`, + `{}`, + } { + if w := env.do(t, http.MethodPost, "/api/console/users", body); w.Code != http.StatusBadRequest { + t.Fatalf("invalid create %q: got %d, want 400 (body %s)", body, w.Code, w.Body.String()) + } + } + + // The viewer can log in; resetting the password invalidates that session. + viewer := loginAs(t, env.engine, "ops-viewer", "viewer-pass-123") + w = env.do(t, http.MethodPost, fmt.Sprintf("/api/console/users/%d/password", created.ID), + `{"password":"new-pass-456"}`) + if w.Code != http.StatusOK || w.Body.String() != `{"ok":true}` { + t.Fatalf("reset password: got %d %s", w.Code, w.Body.String()) + } + // Reset forces a password change and drops every session of the account. + var reset model.AdminUser + if err := db.DB.First(&reset, created.ID).Error; err != nil { + t.Fatalf("load reset account: %v", err) + } + if !reset.MustChangePassword { + t.Fatal("reset password must force must_change_password=true") + } + if w := apiDo(env.engine, http.MethodGet, "/api/me", "", viewer); w.Code != http.StatusUnauthorized { + t.Fatalf("old session must be invalidated by a password reset, got %d", w.Code) + } + // The new password works. + loginAs(t, env.engine, "ops-viewer", "new-pass-456") + // Unknown account -> 404. + if w := env.do(t, http.MethodPost, "/api/console/users/99999/password", + `{"password":"new-pass-456"}`); w.Code != http.StatusNotFound { + t.Fatalf("reset unknown account: got %d, want 404", w.Code) + } + if w := env.do(t, http.MethodPost, fmt.Sprintf("/api/console/users/%d/password", created.ID), + `{"password":"short"}`); w.Code != http.StatusBadRequest { + t.Fatalf("weak reset password: got %d, want 400", w.Code) + } + + // Self-deletion is refused. + if w := env.do(t, http.MethodDelete, fmt.Sprintf("/api/console/users/%d", adminID), ""); w.Code != http.StatusBadRequest || + !strings.Contains(w.Body.String(), "cannot delete your own account") { + t.Fatalf("delete self: got %d %s", w.Code, w.Body.String()) + } + + // Delete the viewer: row and sessions both disappear. + if w := env.do(t, http.MethodDelete, fmt.Sprintf("/api/console/users/%d", created.ID), ""); w.Code != http.StatusOK || + w.Body.String() != `{"ok":true}` { + t.Fatalf("delete viewer: got %d %s", w.Code, w.Body.String()) + } + var n int64 + if err := db.DB.Model(&model.AdminUser{}).Where("id = ?", created.ID).Count(&n).Error; err != nil { + t.Fatalf("count deleted account: %v", err) + } + if n != 0 { + t.Fatal("deleted console account must be gone") + } + if err := db.DB.Model(&model.AdminSession{}).Where("admin_user_id = ?", created.ID).Count(&n).Error; err != nil { + t.Fatalf("count sessions: %v", err) + } + if n != 0 { + t.Fatal("deleted console account must have no sessions left") + } + if w := env.do(t, http.MethodDelete, fmt.Sprintf("/api/console/users/%d", created.ID), ""); w.Code != http.StatusNotFound { + t.Fatalf("delete missing account: got %d, want 404", w.Code) + } + + // The ledger captured every mutation. + var audits []model.AdminAudit + if err := db.DB.Where("action IN ?", []string{"account_create", "account_password_reset", "account_delete"}). + Find(&audits).Error; err != nil { + t.Fatalf("query account audits: %v", err) + } + var actions []string + for _, a := range audits { + actions = append(actions, a.Action) + if a.TargetType == nil || *a.TargetType != "admin_user" || a.TargetID == nil { + t.Fatalf("account audit row shape: %+v", a) + } + // The ledger must attribute account changes to the ACTING admin, not + // to the created/reset/deleted target account. + if a.AdminName != "admin" { + t.Fatalf("account audit admin_name = %q, want acting admin admin (%+v)", a.AdminName, a) + } + } + for _, want := range []string{"account_create", "account_password_reset", "account_delete"} { + if !contains(actions, want) { + t.Fatalf("missing %s in account audits: %+v", want, actions) + } + } +} + +func TestConsoleUsersAdminOnly(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{payDisabled: true}) + if w := env.do(t, http.MethodPost, "/api/console/users", + `{"username":"plain-viewer","password":"viewer-pass-123","role":"viewer"}`); w.Code != http.StatusOK { + t.Fatalf("seed viewer: got %d", w.Code) + } + viewer := loginAs(t, env.engine, "plain-viewer", "viewer-pass-123") + + for _, route := range []struct { + method, path, body string + }{ + {http.MethodGet, "/api/console/users", ""}, + {http.MethodPost, "/api/console/users", `{"username":"x2","password":"x2-pass-123"}`}, + {http.MethodPost, "/api/console/users/1/password", `{"password":"x2-pass-123"}`}, + {http.MethodDelete, "/api/console/users/1", ""}, + } { + if w := apiDo(env.engine, route.method, route.path, route.body, viewer); w.Code != http.StatusForbidden { + t.Fatalf("%s %s as viewer: got %d, want 403", route.method, route.path, w.Code) + } + } +} diff --git a/app-go/app-admin/internal/router/device.go b/app-go/app-admin/internal/router/device.go new file mode 100644 index 00000000..a4d04d1f --- /dev/null +++ b/app-go/app-admin/internal/router/device.go @@ -0,0 +1,76 @@ +// Device fingerprint helpers. The browser sends a random per-profile +// identifier in X-Device-Id; the server never stores or logs it verbatim, only +// a SHA-256 fingerprint that binds a login session to the device that created +// it. +package router + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "regexp" + "strings" + + "github.com/gin-gonic/gin" +) + +// deviceIDHeader carries the client-generated device identifier. +const deviceIDHeader = "X-Device-Id" + +// devicePrefixLen bounds how much of the fingerprint lands in audit details. +const devicePrefixLen = 12 + +// errDeviceID is returned when the header is missing or malformed. The HTTP +// boundary maps it to 400 with exactly this message. +var errDeviceID = errors.New("X-Device-Id header required (16-128 chars A-Za-z0-9_-)") + +var deviceIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{16,128}$`) + +// deviceFingerprint derives the server-side fingerprint for this request. +// +// off — no header required, empty fingerprint (binding disabled) +// id — sha256("v1|" + device id) +// strict — sha256("v1|" + device id + "|" + User-Agent + "|" + first +// Accept-Language tag), so replaying a stolen id alone is not enough +// +// Both enabled modes reject a missing / malformed header with errDeviceID. +func deviceFingerprint(c *gin.Context, mode string) (string, error) { + if mode == "" || mode == "off" { + return "", nil + } + deviceID := strings.TrimSpace(c.GetHeader(deviceIDHeader)) + if !deviceIDPattern.MatchString(deviceID) { + return "", errDeviceID + } + material := "v1|" + deviceID + if mode == "strict" { + material += "|" + c.Request.UserAgent() + "|" + firstLanguageTag(c.GetHeader("Accept-Language")) + } + sum := sha256.Sum256([]byte(material)) + return hex.EncodeToString(sum[:]), nil +} + +// devicePrefix returns the displayable prefix of a fingerprint for audit +// details and comparisons; the full hash never leaves the session row. +func devicePrefix(fingerprint string) string { + if len(fingerprint) > devicePrefixLen { + return fingerprint[:devicePrefixLen] + } + return fingerprint +} + +// firstLanguageTag isolates the primary tag of an Accept-Language header: +// "zh-CN,zh;q=0.9,en;q=0.8" -> "zh-CN". Quality factors and the fallback list +// are noise for a fingerprint and are dropped. +func firstLanguageTag(header string) string { + for _, part := range strings.Split(header, ",") { + tag := strings.TrimSpace(part) + if i := strings.IndexByte(tag, ';'); i >= 0 { + tag = strings.TrimSpace(tag[:i]) + } + if tag != "" { + return tag + } + } + return "" +} diff --git a/app-go/app-admin/internal/router/pages.go b/app-go/app-admin/internal/router/pages.go index 96b630e4..0b73a093 100644 --- a/app-go/app-admin/internal/router/pages.go +++ b/app-go/app-admin/internal/router/pages.go @@ -1,6 +1,6 @@ // Shared query-param helpers for the paginated read endpoints (users / // shares / audit): page starts at 1, pageSize in 1..100, default 20 -// (plan/1.0.16 pagination convention). +// (console pagination convention). package router import ( diff --git a/app-go/app-admin/internal/router/pay.go b/app-go/app-admin/internal/router/pay.go new file mode 100644 index 00000000..23934cf3 --- /dev/null +++ b/app-go/app-admin/internal/router/pay.go @@ -0,0 +1,478 @@ +// Payment-operations endpoints. +// +// Gating layers, outermost first: +// 1. session gate (every /api route); +// 2. payGate — 503 {"detail":"支付管理未配置"} when the second GORM +// connection is absent (pay_rdbms.url unset or app_pay unreachable); +// 3. refundGate — the refund review group is INVISIBLE (404) unless +// pay.ops.base_url + pay.ops.key are wired AND the master switch is on; +// 4. requireAdmin — grant + refund decisions are fund operations. +package router + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "app-admin/internal/dto" + "app-admin/internal/repo" + "app-admin/internal/service" + + "github.com/gin-gonic/gin" +) + +const ( + // payEventReasonMaxRunes is pay_membership_event.reason's column width + // (VARCHAR(200)); the "[operator] " prefix counts against it. + payEventReasonMaxRunes = 200 + // payGrantMaxDays is a sanity cap (10 years) against fat-finger values; + // app-pay itself only enforces days >= 1. + payGrantMaxDays = 3650 +) + +// registerPayRoutes mounts the pay-domain surface. Reads are viewer+; grant and +// refund decisions sit behind requireAdmin. +func (r *Router) registerPayRoutes(api *gin.RouterGroup) { + pay := api.Group("/pay", r.payGate()) + pay.GET("/orders", r.apiPayOrders) + pay.GET("/orders/:orderNo", r.apiPayOrderDetail) + pay.GET("/orders/:orderNo/callbacks", r.apiPayOrderCallbacks) + pay.GET("/memberships", r.apiPayMemberships) + pay.GET("/memberships/:uid/events", r.apiPayMemberEvents) + pay.GET("/events", r.apiPayEvents) + pay.GET("/products", r.apiPayProducts) + + // Fund write: compensation grant (admin only). + pay.POST("/grants", requireAdmin(), r.apiPayGrant) + + // Refund review: stealth 404 when not wired (gate runs BEFORE the role + // gate, so an unconfigured workbench is indistinguishable from a missing + // route even for a logged-in admin). + refunds := pay.Group("/refunds", r.refundGate(), requireAdmin()) + refunds.GET("", r.apiListRefunds) + refunds.POST("/:orderNo/approve", r.apiApproveRefund) + refunds.POST("/:orderNo/reject", r.apiRejectRefund) +} + +// payGate answers 503 when the payment module is not configured/available; the +// rest of the console is unaffected. +func (r *Router) payGate() gin.HandlerFunc { + return func(c *gin.Context) { + if r.pay == nil { + detail(c, http.StatusServiceUnavailable, "支付管理未配置") + c.Abort() + return + } + c.Next() + } +} + +// refundReviewEnabled reports whether the workbench is wired (ops key present +// + master switch on). +func (r *Router) refundReviewEnabled() bool { + return r.ops != nil && r.ops.Enabled() && r.cfg.Pay.Ops.RefundReviewEnabled +} + +// refundGate aborts with 404 when the workbench is not wired (stealth, same +// posture as app-pay's disabled test endpoints). +func (r *Router) refundGate() gin.HandlerFunc { + return func(c *gin.Context) { + if !r.refundReviewEnabled() { + detail(c, http.StatusNotFound, "refund review not configured") + c.Abort() + return + } + c.Next() + } +} + +// ── queries ───────────────────────────────────────────────────────────── + +// apiPayOrders GET /api/pay/orders?uid=&status=&channel=&sku=&orderNo= +// &tradeNo=&createdFrom=&createdTo=&page=&pageSize= +func (r *Router) apiPayOrders(c *gin.Context) { + page, pageSize, ok := r.parsePage(c) + if !ok { + detail(c, http.StatusBadRequest, "page must be >= 1 and pageSize within 1..100") + return + } + q := service.PayOrderQuery{ + Status: strings.TrimSpace(c.Query("status")), + Channel: strings.TrimSpace(c.Query("channel")), + SkuCode: strings.TrimSpace(c.Query("sku")), + OrderNo: strings.TrimSpace(c.Query("orderNo")), + TradeNo: strings.TrimSpace(c.Query("tradeNo")), + } + if v := c.Query("uid"); v != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil || n < 0 { + detail(c, http.StatusBadRequest, "uid must be a non-negative integer") + return + } + q.UID = &n + } + if v := c.Query("createdFrom"); v != "" { + t, ok := parsePayTime(v, false) + if !ok { + detail(c, http.StatusBadRequest, "invalid createdFrom (expect RFC3339 or YYYY-MM-DD)") + return + } + q.CreatedFrom = &t + } + if v := c.Query("createdTo"); v != "" { + t, ok := parsePayTime(v, true) + if !ok { + detail(c, http.StatusBadRequest, "invalid createdTo (expect RFC3339 or YYYY-MM-DD)") + return + } + q.CreatedTo = &t + } + result, err := r.pay.Orders(q, page, pageSize) + if err != nil { + serverError(c, err, "pay orders") + return + } + c.JSON(http.StatusOK, result) +} + +// apiPayOrderDetail GET /api/pay/orders/:orderNo?tradeNo= — orderNo first, then +// the channel trade number as a fallback trace. +func (r *Router) apiPayOrderDetail(c *gin.Context) { + item, err := r.pay.Order(c.Param("orderNo"), strings.TrimSpace(c.Query("tradeNo"))) + if err != nil { + serverError(c, err, "pay order detail") + return + } + if item == nil { + detail(c, http.StatusNotFound, "order not found") + return + } + c.JSON(http.StatusOK, dto.PayOrderDetail{Order: *item}) +} + +// apiPayOrderCallbacks GET /api/pay/orders/:orderNo/callbacks +func (r *Router) apiPayOrderCallbacks(c *gin.Context) { + page, pageSize, ok := r.parsePage(c) + if !ok { + detail(c, http.StatusBadRequest, "page must be >= 1 and pageSize within 1..100") + return + } + result, err := r.pay.Callbacks(c.Param("orderNo"), page, pageSize) + if err != nil { + serverError(c, err, "pay order callbacks") + return + } + c.JSON(http.StatusOK, result) +} + +// apiPayMemberships GET /api/pay/memberships?uid= — uid exact lookup returns +// {"membership": item|null}; without uid it pages the full table. +func (r *Router) apiPayMemberships(c *gin.Context) { + if v := c.Query("uid"); v != "" { + uid, ok := parsePositiveUID(v) + if !ok { + detail(c, http.StatusBadRequest, "uid must be a positive integer") + return + } + item, err := r.pay.Membership(uid) + if err != nil { + serverError(c, err, "pay membership") + return + } + c.JSON(http.StatusOK, dto.PayMembershipLookup{Membership: item}) + return + } + page, pageSize, ok := r.parsePage(c) + if !ok { + detail(c, http.StatusBadRequest, "page must be >= 1 and pageSize within 1..100") + return + } + result, err := r.pay.Memberships(page, pageSize) + if err != nil { + serverError(c, err, "pay memberships") + return + } + c.JSON(http.StatusOK, result) +} + +// apiPayMemberEvents GET /api/pay/memberships/:uid/events +func (r *Router) apiPayMemberEvents(c *gin.Context) { + uid, ok := parsePositiveUID(c.Param("uid")) + if !ok { + detail(c, http.StatusBadRequest, "uid must be a positive integer") + return + } + page, pageSize, ok := r.parsePage(c) + if !ok { + detail(c, http.StatusBadRequest, "page must be >= 1 and pageSize within 1..100") + return + } + result, err := r.pay.MemberEvents(uid, page, pageSize) + if err != nil { + serverError(c, err, "pay member events") + return + } + c.JSON(http.StatusOK, result) +} + +// apiPayEvents GET /api/pay/events?uid=&type= — the global entitlement stream +// (feeds the ADMIN_GRANT audit review). +func (r *Router) apiPayEvents(c *gin.Context) { + var uid *int64 + if v := c.Query("uid"); v != "" { + n, ok := parsePositiveUID(v) + if !ok { + detail(c, http.StatusBadRequest, "uid must be a positive integer") + return + } + uid = &n + } + page, pageSize, ok := r.parsePage(c) + if !ok { + detail(c, http.StatusBadRequest, "page must be >= 1 and pageSize within 1..100") + return + } + result, err := r.pay.Events(uid, strings.TrimSpace(c.Query("type")), page, pageSize) + if err != nil { + serverError(c, err, "pay events") + return + } + c.JSON(http.StatusOK, result) +} + +// apiPayProducts GET /api/pay/products (read-only SKU catalog). +func (r *Router) apiPayProducts(c *gin.Context) { + result, err := r.pay.Products() + if err != nil { + serverError(c, err, "pay products") + return + } + c.JSON(http.StatusOK, result) +} + +// ── compensation grant (fund write, admin only) ───────────────────────── + +// apiPayGrant POST /api/pay/grants +// +// body {"uid": , "durationDays": int, "reason": string, +// "tier"?: "VIP"|"SVIP"}. The operator identity comes from the console session +// and rides into pay_membership_event.reason as "[] ..." (the +// Java-owned table has no operator column). +func (r *Router) apiPayGrant(c *gin.Context) { + if r.grants == nil { + detail(c, http.StatusServiceUnavailable, "支付管理未配置") + return + } + var req struct { + UID json.RawMessage `json:"uid"` + DurationDays int `json:"durationDays"` + Reason string `json:"reason"` + Tier string `json:"tier"` + } + if err := c.ShouldBindJSON(&req); err != nil { + detail(c, http.StatusBadRequest, "invalid request: "+err.Error()) + return + } + uid, err := parseFlexibleUID(req.UID) + if err != nil || uid <= 0 { + detail(c, http.StatusBadRequest, "uid must be a positive integer") + return + } + if req.DurationDays < 1 || req.DurationDays > payGrantMaxDays { + detail(c, http.StatusBadRequest, "durationDays must be within 1..3650") + return + } + reason := strings.TrimSpace(req.Reason) + if reason == "" { + detail(c, http.StatusBadRequest, "reason required") + return + } + tier := strings.ToUpper(strings.TrimSpace(req.Tier)) + if tier != "" && tier != "VIP" && tier != "SVIP" { + detail(c, http.StatusBadRequest, "tier must be VIP or SVIP") + return + } + operator := operatorOf(c) + if operator == "" { + // Defensive: the session gate always sets an identity. + detail(c, http.StatusUnauthorized, "operator identity missing") + return + } + // Keep the audit reason within the Java column width after the operator + // prefix is prepended. + if utf8.RuneCountInString("["+operator+"] "+reason) > payEventReasonMaxRunes { + detail(c, http.StatusBadRequest, "reason too long: operator prefix + reason must fit 200 chars") + return + } + + res, err := r.grants.Grant(c.Request.Context(), operator, uid, req.DurationDays, reason, tier) + if err != nil { + var quota *service.DailyQuotaError + if errors.As(err, "a) { + detail(c, http.StatusTooManyRequests, quota.Msg) + return + } + slog.Error("[PAY_GRANT] grant failed", "operator", operator, "uid", uid, "err", err) + detail(c, http.StatusInternalServerError, "grant failed") + return + } + c.JSON(http.StatusOK, dto.PayGrantResult{ + UID: strconv.FormatInt(uid, 10), + Tier: res.Tier, + Days: req.DurationDays, + ExpireBefore: payTime(res.Before), + ExpireAfter: payTime(res.After), + Created: res.Created, + Active: res.After.After(time.Now()), + }) +} + +// ── refund review (admin only, stealth 404 when unwired) ──────────────── + +// apiListRefunds GET /api/pay/refunds?status=PENDING|APPROVED|REJECTED|ALL +func (r *Router) apiListRefunds(c *gin.Context) { + status := strings.ToUpper(strings.TrimSpace(c.DefaultQuery("status", "PENDING"))) + switch status { + case "", "ALL": // empty value = no filter (contract: 空 = 全部) + status = "" + case "PENDING", "APPROVED", "REJECTED": + default: + detail(c, http.StatusBadRequest, "status must be PENDING, APPROVED, REJECTED or ALL") + return + } + page, pageSize, ok := r.parsePage(c) + if !ok { + detail(c, http.StatusBadRequest, "page must be >= 1 and pageSize within 1..100") + return + } + result, err := r.pay.Refunds(status, page, pageSize) + if err != nil { + serverError(c, err, "pay refunds") + return + } + c.JSON(http.StatusOK, result) +} + +// apiApproveRefund POST /api/pay/refunds/:orderNo/approve {"reason"?} +func (r *Router) apiApproveRefund(c *gin.Context) { + r.apiRefundAction(c, true) +} + +// apiRejectRefund POST /api/pay/refunds/:orderNo/reject +func (r *Router) apiRejectRefund(c *gin.Context) { + r.apiRefundAction(c, false) +} + +// apiRefundAction proxies the decision to app-pay (the fund write happens +// there), then records the operator action in both local audit trails. The +// app-pay call is authoritative: its {"detail"} is passed through as 502. +func (r *Router) apiRefundAction(c *gin.Context, confirm bool) { + orderNo := strings.TrimSpace(c.Param("orderNo")) + if orderNo == "" || len(orderNo) > 64 { + detail(c, http.StatusBadRequest, "invalid orderNo") + return + } + var req struct { + Reason string `json:"reason"` + } + _ = c.ShouldBindJSON(&req) // optional body: reject carries none + reason := strings.TrimSpace(req.Reason) + operator := operatorOf(c) + if operator == "" { + detail(c, http.StatusUnauthorized, "operator identity missing") + return + } + + if err := r.ops.ConfirmRefundOrReject(c.Request.Context(), orderNo, operator, reason, confirm); err != nil { + slog.Warn("[PAY_REFUND] app-pay decision failed", "operator", operator, "order_no", orderNo, "confirm", confirm, "err", err) + detail(c, http.StatusBadGateway, err.Error()) + return + } + + action, event := "pay_refund_approve", "REFUND_CONFIRMED" + if !confirm { + action, event = "pay_refund_reject", "REFUND_REJECTED" + } + // The money already moved upstream: audit failures are logged loudly but do + // not turn a confirmed refund into an error response. + auditDetail := "orderNo=" + orderNo + if reason != "" { + auditDetail += " reason=" + reason + } + if err := repo.RecordAudit(operator, action, "pay_refund", orderNo, auditDetail); err != nil { + slog.Error("[PAY_REFUND] admin_audit write failed after app-pay decision", "order_no", orderNo, "err", err) + } + if r.payAudit != nil { + r.payAudit.Log(event, "operator", operator, "order_no", orderNo, + "action", action, "source", "app-admin") + } + + c.JSON(http.StatusOK, dto.PayRefundActionResult{ + OK: true, + OrderNo: orderNo, + Confirmed: confirm, + Checker: operator, + }) +} + +// ── parsing helpers ───────────────────────────────────────────────────── + +// operatorOf reads the authenticated console username. +func operatorOf(c *gin.Context) string { + id, ok := identityOf(c) + if !ok { + return "" + } + return id.Username +} + +// parsePositiveUID accepts only a positive int64 (uid > 0). +func parsePositiveUID(value string) (int64, bool) { + n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || n <= 0 { + return 0, false + } + return n, true +} + +// parseFlexibleUID accepts the grant body's uid as a JSON number OR a JSON +// string (the console may pass the string form it renders). +func parseFlexibleUID(raw json.RawMessage) (int64, error) { + value := strings.TrimSpace(string(raw)) + if value == "" || value == "null" { + return 0, errors.New("missing uid") + } + if strings.HasPrefix(value, "\"") { + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return 0, err + } + value = strings.TrimSpace(s) + } + return strconv.ParseInt(value, 10, 64) +} + +// parsePayTime accepts RFC3339 or a plain YYYY-MM-DD date interpreted in +// time.Local (Asia/Shanghai, pinned by main). A bare end date includes the +// whole day (createdTo=2026-11-03 matches 23:59:59 that day). +func parsePayTime(value string, endOfDay bool) (time.Time, bool) { + value = strings.TrimSpace(value) + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t, true + } + if t, err := time.ParseInLocation("2006-01-02", value, time.Local); err == nil { + if endOfDay { + return t.AddDate(0, 0, 1).Add(-time.Nanosecond), true + } + return t, true + } + return time.Time{}, false +} + +// payTime renders a required instant in the contract's RFC3339 format. +func payTime(t time.Time) string { return t.Format(time.RFC3339) } diff --git a/app-go/app-admin/internal/router/pay_test.go b/app-go/app-admin/internal/router/pay_test.go new file mode 100644 index 00000000..9cf1092b --- /dev/null +++ b/app-go/app-admin/internal/router/pay_test.go @@ -0,0 +1,1088 @@ +package router + +// Payment-operations console tests: query endpoints, the +// compensation grant fund write, and the refund-review proxy. Everything runs +// against TWO in-memory SQLite databases (main admin_* tables + app_pay row +// mappings), mirroring the production split without MySQL. + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "app-admin/internal/config" + "app-admin/internal/db" + "app-admin/internal/model" + "app-admin/internal/payops" + "app-admin/internal/repo" + "app-admin/internal/service" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// ── shared test harness ───────────────────────────────────────────────── + +// setupTestDBs swaps BOTH global GORM handles for fresh in-memory SQLite +// databases: the main DB gets the admin_* own tables, the pay DB gets the +// pay_* row mappings (production MySQL never migrates them, but the test tables +// mirror the Java schema). Previous handles are restored on cleanup so tests +// that expect "no database" keep working regardless of order. +func setupTestDBs(t *testing.T) { + t.Helper() + prevMain, prevPay := db.DB, db.PayDB + db.DB = openTestSQLite(t, &model.AdminUser{}, &model.AdminSession{}, &model.AdminAudit{}) + db.PayDB = openTestSQLite(t, + &model.PayOrder{}, &model.PayMembership{}, &model.PayMembershipEvent{}, + &model.PayCallbackLog{}, &model.PayProduct{}, &model.PayRefundApproval{}, + ) + t.Cleanup(func() { db.DB, db.PayDB = prevMain, prevPay }) +} + +func openTestSQLite(t *testing.T, models ...any) *gorm.DB { + t.Helper() + gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlDB, err := gdb.DB() + if err != nil { + t.Fatalf("sqlite handle: %v", err) + } + // One connection keeps the :memory: database identical across queries. + sqlDB.SetMaxOpenConns(1) + if err := gdb.AutoMigrate(models...); err != nil { + t.Fatalf("migrate test schema: %v", err) + } + return gdb +} + +// apiDo performs one console request. Unsafe methods carry a same-origin Origin +// header because the browser CSRF gate runs before the session gate (the real +// SPA always sends it). +func apiDo(e http.Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if method != http.MethodGet && method != http.MethodHead { + req.Header.Set("Origin", "http://"+req.Host) + } + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// sessionCookieOf extracts the admin_session value from a login response. +func sessionCookieOf(w *httptest.ResponseRecorder) string { + const prefix = "admin_session=" + ck := w.Header().Get("Set-Cookie") + i := strings.Index(ck, prefix) + if i < 0 { + return "" + } + rest := ck[i+len(prefix):] + if j := strings.IndexByte(rest, ';'); j >= 0 { + rest = rest[:j] + } + return rest +} + +// loginAs logs in and returns the Cookie header. +func loginAs(t *testing.T, e http.Handler, username, password string) map[string]string { + t.Helper() + w := apiDo(e, http.MethodPost, "/api/login", + fmt.Sprintf("{\"username\":%q,\"password\":%q}", username, password), nil) + if w.Code != http.StatusOK { + t.Fatalf("login %s: got %d, body %s", username, w.Code, w.Body.String()) + } + token := sessionCookieOf(w) + if token == "" { + t.Fatalf("login %s returned no session cookie", username) + } + return map[string]string{"Cookie": "admin_session=" + token} +} + +func loginAsAdmin(t *testing.T, e http.Handler) map[string]string { + t.Helper() + return loginAs(t, e, "admin", "app-admin") +} + +func decodeJSON(t *testing.T, body string, v any) { + t.Helper() + if err := json.Unmarshal([]byte(body), v); err != nil { + t.Fatalf("parse %q: %v", body, err) + } +} + +// payTestOptions configures the payment-console test router. +type payTestOptions struct { + payDisabled bool // no Pay deps: /api/pay/** must answer 503 + opsBaseURL string + opsKey string + refundEnabled bool + grantCaps service.GrantCaps +} + +type payTestEnv struct { + engine *gin.Engine + headers map[string]string + auditPath string + cfg *config.Config +} + +// newPayTestEnv builds the unified console with the payment module wired to +// the SQLite pay DB (or deliberately disabled) and logs in as the seeded admin. +func newPayTestEnv(t *testing.T, opt payTestOptions) *payTestEnv { + t.Helper() + setupTestDBs(t) + if err := repo.EnsureSeedAdmin("admin", "app-admin"); err != nil { + t.Fatalf("seed admin: %v", err) + } + cfg := &config.Config{} + cfg.Pay.AuditFile = filepath.Join(t.TempDir(), "pay-audit.jsonl") + cfg.Pay.Ops.BaseURL = opt.opsBaseURL + cfg.Pay.Ops.Key = opt.opsKey + cfg.Pay.Ops.RefundReviewEnabled = opt.refundEnabled + if !opt.payDisabled { + cfg.PayRDBMS.URL = "mysql+aiomysql://u:p@pay-mysql:3306/app_pay" + } + audit := service.NewPayAudit(cfg.Pay.AuditFile) + t.Cleanup(func() { _ = audit.Close() }) + + deps := Deps{ + Auth: service.NewAuthService(0), + Console: service.NewConsoleUserService(), + } + if !opt.payDisabled { + deps.Pay = service.NewPayQueryService() + deps.Grants = service.NewPayGrantServiceWithAudit(audit, opt.grantCaps, repo.RecordAudit) + deps.Ops = payops.New(opt.opsBaseURL, opt.opsKey, 5) + deps.PayAudit = audit + } + e := New(cfg, deps) + return &payTestEnv{ + engine: e, + headers: loginAsAdmin(t, e), + auditPath: cfg.Pay.AuditFile, + cfg: cfg, + } +} + +func (env *payTestEnv) do(t *testing.T, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + return apiDo(env.engine, method, path, body, env.headers) +} + +// ── seeding helpers ───────────────────────────────────────────────────── + +func seedPayOrder(t *testing.T, orderNo string, uid int64, status, channel string, cents int64, createdAt time.Time) *model.PayOrder { + t.Helper() + o := &model.PayOrder{ + OrderNo: orderNo, + UID: uid, + SkuCode: "VIP_MONTHLY", + ProductTitle: "VIP 月卡", + DurationDays: 30, + AmountCents: cents, + Channel: channel, + Status: status, + Version: 1, + ExpiresAt: createdAt.Add(30 * time.Minute), + CreatedAt: createdAt, + UpdatedAt: createdAt, + } + if err := db.PayDB.Create(o).Error; err != nil { + t.Fatalf("seed order: %v", err) + } + return o +} + +func seedPayCallback(t *testing.T, orderNo, sig, handle string) { + t.Helper() + payload := "{\"masked\":\"...\"}" + cb := &model.PayCallbackLog{ + OrderNo: orderNo, + Channel: "ALIPAY", + PayloadMasked: &payload, + SignatureResult: sig, + HandleResult: handle, + CreatedAt: time.Now(), + } + if err := db.PayDB.Create(cb).Error; err != nil { + t.Fatalf("seed callback: %v", err) + } +} + +func seedPayMembership(t *testing.T, uid int64, tier string, expire time.Time, lastOrder *string) { + t.Helper() + now := time.Now() + if err := db.PayDB.Create(&model.PayMembership{ + UID: uid, Tier: tier, ExpireAt: expire, LastOrderNo: lastOrder, + CreatedAt: now, UpdatedAt: now, + }).Error; err != nil { + t.Fatalf("seed membership: %v", err) + } +} + +// ── health ────────────────────────────────────────────────────────────── + +func TestHealthPayStatusMatrix(t *testing.T) { + // Main database unavailable -> 503 database unavailable (existing contract). + setupTestDBs(t) + db.DB = nil + if w := apiDo(New(&config.Config{}, Deps{}), http.MethodGet, "/health", "", nil); w.Code != http.StatusServiceUnavailable || + w.Body.String() != `{"detail":"database unavailable"}` { + t.Fatalf("main db down: got %d %s", w.Code, w.Body.String()) + } + + // pay_rdbms.url configured but the pay connection is not available -> 503. + setupTestDBs(t) + db.PayDB = nil + cfg := &config.Config{} + cfg.PayRDBMS.URL = "mysql+aiomysql://u:p@pay-mysql:3306/app_pay" + w := apiDo(New(cfg, Deps{}), http.MethodGet, "/health", "", nil) + if w.Code != http.StatusServiceUnavailable || w.Body.String() != `{"detail":"pay database unavailable"}` { + t.Fatalf("pay db configured but down: got %d %s", w.Code, w.Body.String()) + } + + // Both databases healthy -> pay:"ok". + setupTestDBs(t) + cfg = &config.Config{} + cfg.PayRDBMS.URL = "mysql+aiomysql://u:p@pay-mysql:3306/app_pay" + w = apiDo(New(cfg, Deps{}), http.MethodGet, "/health", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("healthy: got %d %s", w.Code, w.Body.String()) + } + var health struct { + Status string `json:"status"` + Service string `json:"service"` + Pay string `json:"pay"` + } + decodeJSON(t, w.Body.String(), &health) + if health.Status != "healthy" || health.Service != "app-admin" || health.Pay != "ok" { + t.Fatalf("healthy body: %s", w.Body.String()) + } + + // Pay intentionally unconfigured -> 200 pay:"disabled" (not an outage). + setupTestDBs(t) + db.PayDB = nil // no second connection configured + cfg = &config.Config{} + w = apiDo(New(cfg, Deps{}), http.MethodGet, "/health", "", nil) + decodeJSON(t, w.Body.String(), &health) + if w.Code != http.StatusOK || health.Pay != "disabled" { + t.Fatalf("pay disabled health: got %d %s", w.Code, w.Body.String()) + } +} + +// ── pay module gate ───────────────────────────────────────────────────── + +func TestPayRoutesDisabledReturns503(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{payDisabled: true}) + + // Session gate wins first: anonymous callers get 401, not the pay hint. + if w := apiDo(env.engine, http.MethodGet, "/api/pay/orders", "", nil); w.Code != http.StatusUnauthorized { + t.Fatalf("anonymous pay call must be 401, got %d", w.Code) + } + w := env.do(t, http.MethodGet, "/api/pay/orders", "") + if w.Code != http.StatusServiceUnavailable || w.Body.String() != `{"detail":"支付管理未配置"}` { + t.Fatalf("disabled pay module must answer 503 detail, got %d %s", w.Code, w.Body.String()) + } + // Every pay route shares the gate. + for _, path := range []string{"/api/pay/memberships", "/api/pay/events", "/api/pay/products", "/api/pay/refunds"} { + if w := env.do(t, http.MethodGet, path, ""); w.Code != http.StatusServiceUnavailable { + t.Fatalf("%s must be 503 when pay is disabled, got %d", path, w.Code) + } + } + // Account management is independent of the payment DB and keeps working. + if w := env.do(t, http.MethodGet, "/api/console/users", ""); w.Code != http.StatusOK { + t.Fatalf("console users must work with pay disabled, got %d %s", w.Code, w.Body.String()) + } +} + +// ── orders ────────────────────────────────────────────────────────────── + +func TestPayOrdersListAndFilters(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{}) + now := time.Now() + seedPayOrder(t, "PO-A", 1001, "DELIVERED", "ALIPAY", 1800, now.Add(-3*time.Hour)) + seedPayOrder(t, "PO-B", 1001, "CLOSED", "MOCK", 4500, now.Add(-2*time.Hour)) + seedPayOrder(t, "PO-C", 1002, "CREATED", "ALIPAY", 15800, now.Add(-1*time.Hour)) + // Channel trade number on PO-C for the tradeNo filter / detail fallback. + trade := "TRADE-C" + if err := db.PayDB.Model(&model.PayOrder{}).Where("order_no = ?", "PO-C"). + Update("channel_trade_no", trade).Error; err != nil { + t.Fatalf("seed trade no: %v", err) + } + + type orderPage struct { + Items []struct { + OrderNo string `json:"orderNo"` + UID string `json:"uid"` + SkuCode string `json:"skuCode"` + ProductTitle string `json:"productTitle"` + DurationDays int `json:"durationDays"` + AmountCents int64 `json:"amountCents"` + Channel string `json:"channel"` + Status string `json:"status"` + Version int `json:"version"` + ChannelTradeNo *string `json:"channelTradeNo"` + PaidAt *string `json:"paidAt"` + ClosedAt *string `json:"closedAt"` + ExpiresAt string `json:"expiresAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + } + + w := env.do(t, http.MethodGet, "/api/pay/orders", "") + if w.Code != http.StatusOK { + t.Fatalf("list orders: got %d, body %s", w.Code, w.Body.String()) + } + // uid is a JSON STRING (bigint precision safety), not a number. + if !strings.Contains(w.Body.String(), `"uid":"1002"`) { + t.Fatalf("uid must serialise as a string: %s", w.Body.String()) + } + // Nullable columns serialise as null, not "". + if !strings.Contains(w.Body.String(), `"paidAt":null`) || !strings.Contains(w.Body.String(), `"failReason":null`) { + t.Fatalf("nullable columns must be null: %s", w.Body.String()) + } + var page orderPage + decodeJSON(t, w.Body.String(), &page) + if page.Total != 3 || len(page.Items) != 3 || page.Page != 1 || page.PageSize != 20 { + t.Fatalf("orders envelope: %s", w.Body.String()) + } + // ORDER BY id DESC: newest inserted (PO-C) first. + if page.Items[0].OrderNo != "PO-C" || page.Items[2].OrderNo != "PO-A" { + t.Fatalf("orders must be newest first: %s", w.Body.String()) + } + if page.Items[0].AmountCents != 15800 || page.Items[0].Channel != "ALIPAY" || + page.Items[0].SkuCode != "VIP_MONTHLY" || page.Items[0].ProductTitle != "VIP 月卡" || + page.Items[0].DurationDays != 30 || page.Items[0].Version != 1 || + page.Items[0].ExpiresAt == "" || page.Items[0].CreatedAt == "" || page.Items[0].UpdatedAt == "" { + t.Fatalf("order item fields: %+v", page.Items[0]) + } + if page.Items[0].ChannelTradeNo == nil || *page.Items[0].ChannelTradeNo != trade { + t.Fatalf("channelTradeNo: %+v", page.Items[0].ChannelTradeNo) + } + + // Filters. + checkTotal := func(query string, want int64) { + t.Helper() + w := env.do(t, http.MethodGet, "/api/pay/orders?"+query, "") + if w.Code != http.StatusOK { + t.Fatalf("%s: got %d, body %s", query, w.Code, w.Body.String()) + } + var p orderPage + decodeJSON(t, w.Body.String(), &p) + if p.Total != want { + t.Fatalf("%s: total=%d, want %d (body %s)", query, p.Total, want, w.Body.String()) + } + } + checkTotal("uid=1001", 2) + checkTotal("status=CLOSED", 1) + checkTotal("channel=MOCK", 1) + checkTotal("sku=VIP_MONTHLY", 3) + checkTotal("orderNo=PO-C", 1) + checkTotal("tradeNo="+trade, 1) + checkTotal("createdFrom="+url.QueryEscape(now.Add(-150*time.Minute).Format(time.RFC3339)), 2) + checkTotal("createdFrom=2020-01-01", 3) + checkTotal("createdTo=2020-01-01", 0) + checkTotal("createdTo="+url.QueryEscape(now.Add(24*time.Hour).Format("2006-01-02")), 3) + + // Pagination + validation. + if w := env.do(t, http.MethodGet, "/api/pay/orders?page=2&pageSize=2", ""); w.Code != http.StatusOK { + t.Fatalf("page 2: got %d", w.Code) + } else { + var p orderPage + decodeJSON(t, w.Body.String(), &p) + if len(p.Items) != 1 || p.Page != 2 || p.PageSize != 2 { + t.Fatalf("page 2 window: %s", w.Body.String()) + } + } + for _, bad := range []string{ + "/api/pay/orders?uid=abc", + "/api/pay/orders?uid=-1", + "/api/pay/orders?createdFrom=not-a-date", + "/api/pay/orders?createdTo=2026-13-40", + "/api/pay/orders?page=0", + "/api/pay/orders?pageSize=101", + } { + if w := env.do(t, http.MethodGet, bad, ""); w.Code != http.StatusBadRequest { + t.Fatalf("%s must be 400, got %d body %s", bad, w.Code, w.Body.String()) + } + } +} + +func TestPayOrderDetailAndCallbacks(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{}) + seedPayOrder(t, "PO-D", 2001, "PAID", "ALIPAY", 1800, time.Now()) + seedPayCallback(t, "PO-D", "VALID", "ACCEPTED") + seedPayCallback(t, "PO-D", "INVALID", "IGNORED") + trade := "TRADE-D" + if err := db.PayDB.Model(&model.PayOrder{}).Where("order_no = ?", "PO-D"). + Update("channel_trade_no", trade).Error; err != nil { + t.Fatalf("seed trade no: %v", err) + } + + w := env.do(t, http.MethodGet, "/api/pay/orders/PO-D", "") + if w.Code != http.StatusOK { + t.Fatalf("order detail: got %d, body %s", w.Code, w.Body.String()) + } + var detail struct { + Order struct { + OrderNo string `json:"orderNo"` + UID string `json:"uid"` + AmountCents int64 `json:"amountCents"` + PaidAt *string `json:"paidAt"` + ClosedAt *string `json:"closedAt"` + } `json:"order"` + } + decodeJSON(t, w.Body.String(), &detail) + if detail.Order.OrderNo != "PO-D" || detail.Order.UID != "2001" || + detail.Order.AmountCents != 1800 || detail.Order.PaidAt != nil || detail.Order.ClosedAt != nil { + t.Fatalf("order detail body: %s", w.Body.String()) + } + + // tradeNo fallback when the order number is unknown. + w = env.do(t, http.MethodGet, "/api/pay/orders/NOPE?tradeNo="+trade, "") + if w.Code != http.StatusOK { + t.Fatalf("trade no fallback: got %d, body %s", w.Code, w.Body.String()) + } + decodeJSON(t, w.Body.String(), &detail) + if detail.Order.OrderNo != "PO-D" { + t.Fatalf("trade no fallback resolved wrong order: %s", w.Body.String()) + } + + // Unknown order -> 404 with the detail contract. + w = env.do(t, http.MethodGet, "/api/pay/orders/PO-NOPE", "") + if w.Code != http.StatusNotFound || w.Body.String() != `{"detail":"order not found"}` { + t.Fatalf("unknown order: got %d %s", w.Code, w.Body.String()) + } + + // Callback ledger, newest first. + w = env.do(t, http.MethodGet, "/api/pay/orders/PO-D/callbacks", "") + if w.Code != http.StatusOK { + t.Fatalf("callbacks: got %d, body %s", w.Code, w.Body.String()) + } + var cbs struct { + Items []struct { + OrderNo string `json:"orderNo"` + Channel string `json:"channel"` + PayloadMasked *string `json:"payloadMasked"` + SignatureResult string `json:"signatureResult"` + HandleResult string `json:"handleResult"` + Message *string `json:"message"` + CreatedAt string `json:"createdAt"` + } `json:"items"` + Total int64 `json:"total"` + } + decodeJSON(t, w.Body.String(), &cbs) + if cbs.Total != 2 || len(cbs.Items) != 2 || cbs.Items[0].SignatureResult != "INVALID" || + cbs.Items[1].SignatureResult != "VALID" || cbs.Items[0].PayloadMasked == nil || + cbs.Items[0].CreatedAt == "" { + t.Fatalf("callbacks body: %s", w.Body.String()) + } +} + +// ── memberships / events / products ───────────────────────────────────── + +func TestPayMembershipsEventsProducts(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{}) + now := time.Now() + lastOrder := "PO-X" + seedPayMembership(t, 3001, "VIP", now.Add(10*24*time.Hour), &lastOrder) + if err := db.PayDB.Create(&model.PayMembershipEvent{ + UID: 3001, Type: "ACTIVATE", Days: 30, + ExpireBefore: now, ExpireAfter: now.Add(10 * 24 * time.Hour), CreatedAt: now, + }).Error; err != nil { + t.Fatalf("seed event: %v", err) + } + for i, p := range []model.PayProduct{ + {Code: "VIP_YEARLY", Title: "VIP 年卡", DurationDays: 365, PriceCents: 15800, Status: "ACTIVE", Sort: 2, CreatedAt: now, UpdatedAt: now}, + {Code: "VIP_MONTHLY", Title: "VIP 月卡", DurationDays: 30, PriceCents: 1800, Status: "ACTIVE", Sort: 0, CreatedAt: now, UpdatedAt: now}, + } { + if err := db.PayDB.Create(&p).Error; err != nil { + t.Fatalf("seed product %d: %v", i, err) + } + } + + // uid exact lookup -> {"membership": item}; active is lazy expiry. + w := env.do(t, http.MethodGet, "/api/pay/memberships?uid=3001", "") + if w.Code != http.StatusOK { + t.Fatalf("membership lookup: got %d, body %s", w.Code, w.Body.String()) + } + var lookup struct { + Membership *struct { + UID string `json:"uid"` + Tier string `json:"tier"` + Active bool `json:"active"` + ExpireAt string `json:"expireAt"` + AutoRenew bool `json:"autoRenew"` + LastOrderNo *string `json:"lastOrderNo"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"membership"` + } + decodeJSON(t, w.Body.String(), &lookup) + if lookup.Membership == nil || lookup.Membership.UID != "3001" || lookup.Membership.Tier != "VIP" || + !lookup.Membership.Active || lookup.Membership.LastOrderNo == nil || *lookup.Membership.LastOrderNo != "PO-X" { + t.Fatalf("membership lookup body: %s", w.Body.String()) + } + + // Unknown uid -> {"membership": null}, never 404. + w = env.do(t, http.MethodGet, "/api/pay/memberships?uid=9999", "") + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"membership":null`) { + t.Fatalf("unknown membership: got %d %s", w.Code, w.Body.String()) + } + for _, bad := range []string{"/api/pay/memberships?uid=0", "/api/pay/memberships?uid=abc"} { + if w := env.do(t, http.MethodGet, bad, ""); w.Code != http.StatusBadRequest { + t.Fatalf("%s must be 400, got %d", bad, w.Code) + } + } + + // Full listing. + w = env.do(t, http.MethodGet, "/api/pay/memberships", "") + var memberPage struct { + Items []struct { + UID string `json:"uid"` + } `json:"items"` + Total int64 `json:"total"` + } + decodeJSON(t, w.Body.String(), &memberPage) + if w.Code != http.StatusOK || memberPage.Total != 1 || len(memberPage.Items) != 1 || memberPage.Items[0].UID != "3001" { + t.Fatalf("membership list: got %d %s", w.Code, w.Body.String()) + } + + // Per-member event stream. + w = env.do(t, http.MethodGet, "/api/pay/memberships/3001/events", "") + var evPage struct { + Items []struct { + ID int64 `json:"id"` + UID string `json:"uid"` + Type string `json:"type"` + OrderNo *string `json:"orderNo"` + Days int `json:"days"` + ExpireBefore string `json:"expireBefore"` + ExpireAfter string `json:"expireAfter"` + Reason *string `json:"reason"` + CreatedAt string `json:"createdAt"` + } `json:"items"` + Total int64 `json:"total"` + } + decodeJSON(t, w.Body.String(), &evPage) + if w.Code != http.StatusOK || evPage.Total != 1 || evPage.Items[0].Type != "ACTIVATE" || + evPage.Items[0].UID != "3001" || evPage.Items[0].Days != 30 || evPage.Items[0].OrderNo != nil || + evPage.Items[0].Reason != nil || evPage.Items[0].CreatedAt == "" { + t.Fatalf("member events: got %d %s", w.Code, w.Body.String()) + } + + // Global stream with type / uid filters. + if w := env.do(t, http.MethodGet, "/api/pay/events?type=ADMIN_GRANT", ""); w.Code != http.StatusOK { + t.Fatalf("global events: got %d", w.Code) + } else { + var p struct { + Total int64 `json:"total"` + } + decodeJSON(t, w.Body.String(), &p) + if p.Total != 0 { + t.Fatalf("ADMIN_GRANT filter should be empty: %s", w.Body.String()) + } + } + if w := env.do(t, http.MethodGet, "/api/pay/events?uid=3001", ""); w.Code != http.StatusOK { + t.Fatalf("global events uid filter: got %d", w.Code) + } + + // SKU catalog ordered sort ASC, id ASC. + w = env.do(t, http.MethodGet, "/api/pay/products", "") + var products struct { + Items []struct { + Code string `json:"code"` + Title string `json:"title"` + DurationDays int `json:"durationDays"` + PriceCents int64 `json:"priceCents"` + Status string `json:"status"` + Sort int `json:"sort"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"items"` + } + decodeJSON(t, w.Body.String(), &products) + if w.Code != http.StatusOK || len(products.Items) != 2 || + products.Items[0].Code != "VIP_MONTHLY" || products.Items[1].Code != "VIP_YEARLY" || + products.Items[0].PriceCents != 1800 { + t.Fatalf("products: got %d %s", w.Code, w.Body.String()) + } +} + +// ── compensation grant (fund write) ───────────────────────────────────── + +func TestPayGrantCreatesMembershipAndAudits(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{}) + + w := env.do(t, http.MethodPost, "/api/pay/grants", + `{"uid":5001,"durationDays":30,"reason":"客诉补偿"}`) + if w.Code != http.StatusOK { + t.Fatalf("grant: got %d, body %s", w.Code, w.Body.String()) + } + var resp struct { + UID string `json:"uid"` + Tier string `json:"tier"` + Days int `json:"days"` + ExpireBefore string `json:"expireBefore"` + ExpireAfter string `json:"expireAfter"` + Created bool `json:"created"` + Active bool `json:"active"` + } + decodeJSON(t, w.Body.String(), &resp) + if resp.UID != "5001" || resp.Tier != "VIP" || resp.Days != 30 || !resp.Created || !resp.Active || + resp.ExpireBefore == "" || resp.ExpireAfter == "" { + t.Fatalf("grant response: %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"uid":"5001"`) { + t.Fatalf("grant response uid must be a string: %s", w.Body.String()) + } + after, err := time.Parse(time.RFC3339, resp.ExpireAfter) + if err != nil { + t.Fatalf("expireAfter parse: %v", err) + } + want := time.Now().AddDate(0, 0, 30) + if diff := after.Sub(want); diff > time.Minute || diff < -time.Minute { + t.Fatalf("expireAfter = %s, want ~%s", after, want) + } + + // DB effects: membership row + ADMIN_GRANT event with operator prefix. + m, err := repo.GetMembershipByUID(5001) + if err != nil || m == nil { + t.Fatalf("membership after grant: %v %v", m, err) + } + if m.Tier != "VIP" { + t.Fatalf("membership tier: %s", m.Tier) + } + evs, total, err := repo.ListEventsByUID(5001, 10, 0) + if err != nil || total != 1 { + t.Fatalf("events after grant: total=%d err=%v", total, err) + } + if evs[0].Type != "ADMIN_GRANT" || evs[0].Days != 30 || evs[0].Reason == nil || + *evs[0].Reason != "[admin] 客诉补偿" { + t.Fatalf("grant event: %+v", evs[0]) + } + + // admin_audit row (main DB). + var audits []model.AdminAudit + if err := db.DB.Where("action = ?", "pay_grant").Find(&audits).Error; err != nil { + t.Fatalf("query admin_audit: %v", err) + } + if len(audits) != 1 || audits[0].AdminName != "admin" || audits[0].TargetType == nil || + *audits[0].TargetType != "membership" || audits[0].TargetID == nil || *audits[0].TargetID != "5001" || + audits[0].Detail == nil || !strings.Contains(*audits[0].Detail, "days=30") || + !strings.Contains(*audits[0].Detail, "tier=VIP") || !strings.Contains(*audits[0].Detail, "客诉补偿") { + t.Fatalf("admin_audit row: %+v", audits) + } + + // JSONL fund trail: hash-chained MEMBERSHIP_EXTENDED line. + raw, err := os.ReadFile(env.auditPath) + if err != nil { + t.Fatalf("read audit jsonl: %v", err) + } + text := string(raw) + for _, want := range []string{ + `"event":"MEMBERSHIP_EXTENDED"`, `"operator":"admin"`, `"uid":"5001"`, + `"days":30`, `"created":true`, `"source":"app-admin"`, `"chain":"ch-`, `"prev":"GENESIS"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("audit jsonl missing %s: %s", want, text) + } + } +} + +func TestPayGrantAcceptsStringUID(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{}) + w := env.do(t, http.MethodPost, "/api/pay/grants", + `{"uid":"5002","durationDays":5,"reason":"字符串 uid"}`) + if w.Code != http.StatusOK { + t.Fatalf("grant with string uid: got %d, body %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"uid":"5002"`) { + t.Fatalf("string uid response: %s", w.Body.String()) + } +} + +func TestPayGrantExtendSemantics(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{}) + now := time.Now() + + // Active member: extend from expire_at, preserve last_order_no. + expire := now.Add(10 * 24 * time.Hour) + lastOrder := "PO-KEEP" + seedPayMembership(t, 5003, "VIP", expire, &lastOrder) + if w := env.do(t, http.MethodPost, "/api/pay/grants", + `{"uid":5003,"durationDays":5,"reason":"延时"}`); w.Code != http.StatusOK { + t.Fatalf("grant active member: got %d, body %s", w.Code, w.Body.String()) + } + m, _ := repo.GetMembershipByUID(5003) + if want := expire.AddDate(0, 0, 5); m.ExpireAt.Sub(want) > time.Minute || m.ExpireAt.Sub(want) < -time.Minute { + t.Fatalf("active extend expire_at = %s, want ~%s", m.ExpireAt, want) + } + if m.LastOrderNo == nil || *m.LastOrderNo != "PO-KEEP" { + t.Fatalf("last_order_no must be preserved, got %+v", m.LastOrderNo) + } + + // Expired member: restart from now. + seedPayMembership(t, 5004, "VIP", now.Add(-48*time.Hour), nil) + if w := env.do(t, http.MethodPost, "/api/pay/grants", + `{"uid":5004,"durationDays":7,"reason":"恢复"}`); w.Code != http.StatusOK { + t.Fatalf("grant expired member: got %d", w.Code) + } + m, _ = repo.GetMembershipByUID(5004) + if want := time.Now().AddDate(0, 0, 7); m.ExpireAt.Sub(want) > time.Minute || m.ExpireAt.Sub(want) < -time.Minute { + t.Fatalf("expired extend expire_at = %s, want ~%s", m.ExpireAt, want) + } + + // Explicit tier upgrade on an existing membership. + seedPayMembership(t, 5005, "VIP", now.Add(24*time.Hour), nil) + w := env.do(t, http.MethodPost, "/api/pay/grants", + `{"uid":5005,"durationDays":30,"reason":"升档","tier":"SVIP"}`) + if w.Code != http.StatusOK { + t.Fatalf("grant tier: got %d, body %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"tier":"SVIP"`) { + t.Fatalf("tier response: %s", w.Body.String()) + } + if m, _ := repo.GetMembershipByUID(5005); m.Tier != "SVIP" { + t.Fatalf("membership tier after SVIP grant: %s", m.Tier) + } +} + +func TestPayGrantValidation(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{}) + cases := []struct { + name string + body string + }{ + {"uid zero", `{"uid":0,"durationDays":30,"reason":"x"}`}, + {"uid negative", `{"uid":-5,"durationDays":30,"reason":"x"}`}, + {"uid float", `{"uid":1.5,"durationDays":30,"reason":"x"}`}, + {"uid missing", `{"durationDays":30,"reason":"x"}`}, + {"days zero", `{"uid":1,"durationDays":0,"reason":"x"}`}, + {"days too large", `{"uid":1,"durationDays":4000,"reason":"x"}`}, + {"empty reason", `{"uid":1,"durationDays":30,"reason":" "}`}, + {"invalid tier", `{"uid":1,"durationDays":30,"reason":"x","tier":"GOLD"}`}, + {"reason overflow", fmt.Sprintf(`{"uid":1,"durationDays":30,"reason":%q}`, strings.Repeat("长", 200))}, + {"broken json", `{"uid":`}, + } + for _, tc := range cases { + if w := env.do(t, http.MethodPost, "/api/pay/grants", tc.body); w.Code != http.StatusBadRequest { + t.Fatalf("%s: got %d, want 400 (body %s)", tc.name, w.Code, w.Body.String()) + } + } + // Nothing was written by rejected requests. + if _, total, _ := repo.ListEvents(repo.EventFilter{}); total != 0 { + t.Fatalf("rejected grants must not write events, got %d", total) + } +} + +func TestPayGrantViewerForbidden(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{}) + if w := env.do(t, http.MethodPost, "/api/console/users", + `{"username":"pay-viewer","password":"viewer-pass-123","role":"viewer"}`); w.Code != http.StatusOK { + t.Fatalf("seed viewer: got %d, body %s", w.Code, w.Body.String()) + } + viewer := loginAs(t, env.engine, "pay-viewer", "viewer-pass-123") + + // Viewer CAN read the pay domain... + if w := apiDo(env.engine, http.MethodGet, "/api/pay/orders", "", viewer); w.Code != http.StatusOK { + t.Fatalf("viewer list orders: got %d", w.Code) + } + // ...but cannot grant. + w := apiDo(env.engine, http.MethodPost, "/api/pay/grants", + `{"uid":1,"durationDays":30,"reason":"x"}`, viewer) + if w.Code != http.StatusForbidden { + t.Fatalf("viewer grant: got %d, want 403 (body %s)", w.Code, w.Body.String()) + } +} + +func TestPayGrantDailyQuota(t *testing.T) { + env := newPayTestEnv(t, payTestOptions{grantCaps: service.GrantCaps{DailyMaxCount: 1}}) + if w := env.do(t, http.MethodPost, "/api/pay/grants", + `{"uid":6001,"durationDays":10,"reason":"first"}`); w.Code != http.StatusOK { + t.Fatalf("first grant: got %d, body %s", w.Code, w.Body.String()) + } + w := env.do(t, http.MethodPost, "/api/pay/grants", + `{"uid":6002,"durationDays":10,"reason":"second"}`) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("second grant must hit the daily cap: got %d, body %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "日补偿开通次数已达上限") { + t.Fatalf("quota detail: %s", w.Body.String()) + } + // The rejection is itself audited in the fund trail. + raw, err := os.ReadFile(env.auditPath) + if err != nil { + t.Fatalf("read audit jsonl: %v", err) + } + if !strings.Contains(string(raw), `"event":"GRANT_DAILY_COUNT_EXCEEDED"`) { + t.Fatalf("quota breach must be audited: %s", raw) + } + // No membership/event written for the rejected grant. + if m, _ := repo.GetMembershipByUID(6002); m != nil { + t.Fatal("quota-rejected grant must not create a membership") + } + + // Day cap variant. + env2 := newPayTestEnv(t, payTestOptions{grantCaps: service.GrantCaps{DailyMaxDays: 10}}) + w = env2.do(t, http.MethodPost, "/api/pay/grants", + `{"uid":6003,"durationDays":30,"reason":"too many days"}`) + if w.Code != http.StatusTooManyRequests || !strings.Contains(w.Body.String(), "日补偿开通天数已达上限") { + t.Fatalf("day cap: got %d %s", w.Code, w.Body.String()) + } + if raw, err := os.ReadFile(env2.auditPath); err != nil || + !strings.Contains(string(raw), `"event":"GRANT_DAILY_DAYS_EXCEEDED"`) { + t.Fatalf("day-cap breach must be audited: %v %s", err, raw) + } +} + +// ── refund review ─────────────────────────────────────────────────────── + +// refundUpstream is a fake app-pay internal API capturing every call. +type refundUpstream struct { + mu sync.Mutex + calls []recordedCall + status int + body string + server *httptest.Server +} + +type recordedCall struct { + Path string + APIKey string + Body map[string]any +} + +func newRefundUpstream(t *testing.T, status int, body string) *refundUpstream { + t.Helper() + u := &refundUpstream{status: status, body: body} + u.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var parsed map[string]any + _ = json.NewDecoder(r.Body).Decode(&parsed) + u.mu.Lock() + u.calls = append(u.calls, recordedCall{Path: r.URL.Path, APIKey: r.Header.Get("apikey"), Body: parsed}) + status, body := u.status, u.body + u.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(u.server.Close) + return u +} + +func (u *refundUpstream) recorded() []recordedCall { + u.mu.Lock() + defer u.mu.Unlock() + return append([]recordedCall(nil), u.calls...) +} + +func seedRefundApproval(t *testing.T, orderNo, status string) { + t.Helper() + if err := db.PayDB.Create(&model.PayRefundApproval{ + OrderNo: orderNo, Maker: "ops-a", MakerReason: "客诉补偿", + Status: status, CreatedAt: time.Now(), + }).Error; err != nil { + t.Fatalf("seed approval: %v", err) + } +} + +func TestRefundReviewStealth404WhenNotConfigured(t *testing.T) { + // ops key absent: the whole group is invisible (404) even for an admin. + env := newPayTestEnv(t, payTestOptions{refundEnabled: true}) + w := env.do(t, http.MethodGet, "/api/pay/refunds", "") + if w.Code != http.StatusNotFound || w.Body.String() != `{"detail":"refund review not configured"}` { + t.Fatalf("unconfigured refunds: got %d %s", w.Code, w.Body.String()) + } + if w := env.do(t, http.MethodPost, "/api/pay/refunds/PO1/approve", "{}"); w.Code != http.StatusNotFound { + t.Fatalf("unconfigured approve must be 404, got %d", w.Code) + } + // Master switch off with a full key: still 404. + up := newRefundUpstream(t, http.StatusOK, `{"ok":true}`) + env2 := newPayTestEnv(t, payTestOptions{opsBaseURL: up.server.URL, opsKey: "ops-key", refundEnabled: false}) + if w := env2.do(t, http.MethodGet, "/api/pay/refunds", ""); w.Code != http.StatusNotFound { + t.Fatalf("switch-off refunds must be 404, got %d", w.Code) + } +} + +func TestRefundReviewListAndDecisions(t *testing.T) { + up := newRefundUpstream(t, http.StatusOK, `{"ok":true}`) + env := newPayTestEnv(t, payTestOptions{opsBaseURL: up.server.URL, opsKey: "ops-key", refundEnabled: true}) + seedRefundApproval(t, "PO1", "PENDING") + + // List reads pay_refund_approval directly (no upstream call). + w := env.do(t, http.MethodGet, "/api/pay/refunds", "") + if w.Code != http.StatusOK { + t.Fatalf("refund list: got %d, body %s", w.Code, w.Body.String()) + } + var page struct { + Items []struct { + OrderNo string `json:"orderNo"` + Maker string `json:"maker"` + MakerReason string `json:"makerReason"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + DecidedAt *string `json:"decidedAt"` + Checker *string `json:"checker"` + CheckReason *string `json:"checkReason"` + } `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + } + decodeJSON(t, w.Body.String(), &page) + if page.Total != 1 || len(page.Items) != 1 || page.Items[0].OrderNo != "PO1" || + page.Items[0].Maker != "ops-a" || page.Items[0].Status != "PENDING" || + page.Items[0].DecidedAt != nil || page.Items[0].Checker != nil || page.Items[0].CheckReason != nil || + page.Items[0].CreatedAt == "" { + t.Fatalf("refund list body: %s", w.Body.String()) + } + if len(up.recorded()) != 0 { + t.Fatal("refund list must not call the upstream") + } + + // status filters: ALL = no filter, unknown status rejected. + if w := env.do(t, http.MethodGet, "/api/pay/refunds?status=ALL", ""); w.Code != http.StatusOK { + t.Fatalf("status=ALL: got %d", w.Code) + } + if w := env.do(t, http.MethodGet, "/api/pay/refunds?status=", ""); w.Code != http.StatusOK { + t.Fatalf("empty status must mean ALL: got %d", w.Code) + } + if w := env.do(t, http.MethodGet, "/api/pay/refunds?status=BOGUS", ""); w.Code != http.StatusBadRequest { + t.Fatalf("bogus status: got %d, want 400", w.Code) + } + + // Approve: proxies to app-pay with confirm=true + the operator identity. + w = env.do(t, http.MethodPost, "/api/pay/refunds/PO1/approve", `{"reason":"复核通过"}`) + if w.Code != http.StatusOK { + t.Fatalf("approve: got %d, body %s", w.Code, w.Body.String()) + } + var result struct { + OK bool `json:"ok"` + OrderNo string `json:"orderNo"` + Confirmed bool `json:"confirmed"` + Checker string `json:"checker"` + } + decodeJSON(t, w.Body.String(), &result) + if !result.OK || result.OrderNo != "PO1" || !result.Confirmed || result.Checker != "admin" { + t.Fatalf("approve response: %s", w.Body.String()) + } + calls := up.recorded() + if len(calls) != 1 || calls[0].Path != "/internal/pay/orders/PO1/refund" { + t.Fatalf("approve upstream call: %+v", calls) + } + if calls[0].APIKey != "ops-key" { + t.Fatalf("ops key must ride on the upstream call, got %q", calls[0].APIKey) + } + if calls[0].Body["operator"] != "admin" || calls[0].Body["confirm"] != true || calls[0].Body["reason"] != "复核通过" { + t.Fatalf("approve upstream body: %+v", calls[0].Body) + } + + // Reject: same proxy path with the reject endpoint. + w = env.do(t, http.MethodPost, "/api/pay/refunds/PO1/reject", "{}") + if w.Code != http.StatusOK { + t.Fatalf("reject: got %d, body %s", w.Code, w.Body.String()) + } + decodeJSON(t, w.Body.String(), &result) + if !result.OK || result.Confirmed || result.Checker != "admin" { + t.Fatalf("reject response: %s", w.Body.String()) + } + calls = up.recorded() + if len(calls) != 2 || calls[1].Path != "/internal/pay/orders/PO1/refund/reject" || + calls[1].Body["operator"] != "admin" { + t.Fatalf("reject upstream call: %+v", calls) + } + + // Both decisions are in the app-admin ledger + the JSONL fund trail. + var actions []string + { + var audits []model.AdminAudit + if err := db.DB.Where("target_type = ?", "pay_refund").Find(&audits).Error; err != nil { + t.Fatalf("query refund audits: %v", err) + } + for _, a := range audits { + actions = append(actions, a.Action) + } + } + if len(actions) != 2 || !contains(actions, "pay_refund_approve") || !contains(actions, "pay_refund_reject") { + t.Fatalf("refund audit actions: %+v", actions) + } + raw, err := os.ReadFile(env.auditPath) + if err != nil { + t.Fatalf("read audit jsonl: %v", err) + } + if !strings.Contains(string(raw), `"event":"REFUND_CONFIRMED"`) || + !strings.Contains(string(raw), `"event":"REFUND_REJECTED"`) { + t.Fatalf("refund JSONL events: %s", raw) + } +} + +func TestRefundReviewUpstreamFailureMaps502(t *testing.T) { + up := newRefundUpstream(t, http.StatusInternalServerError, `{"detail":"渠道退款失败"}`) + env := newPayTestEnv(t, payTestOptions{opsBaseURL: up.server.URL, opsKey: "ops-key", refundEnabled: true}) + w := env.do(t, http.MethodPost, "/api/pay/refunds/PO9/approve", "{}") + if w.Code != http.StatusBadGateway { + t.Fatalf("upstream failure must map to 502, got %d body %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "渠道退款失败") { + t.Fatalf("upstream detail must be passed through: %s", w.Body.String()) + } + // A failed decision must not write an audit row. + var n int64 + if err := db.DB.Model(&model.AdminAudit{}).Where("target_type = ?", "pay_refund").Count(&n).Error; err != nil { + t.Fatalf("count refund audits: %v", err) + } + if n != 0 { + t.Fatalf("failed upstream call must not be audited, got %d rows", n) + } +} + +func TestRefundReviewRejectsViewer(t *testing.T) { + up := newRefundUpstream(t, http.StatusOK, `{"ok":true}`) + env := newPayTestEnv(t, payTestOptions{opsBaseURL: up.server.URL, opsKey: "ops-key", refundEnabled: true}) + if w := env.do(t, http.MethodPost, "/api/console/users", + `{"username":"refund-viewer","password":"viewer-pass-123","role":"viewer"}`); w.Code != http.StatusOK { + t.Fatalf("seed viewer: got %d", w.Code) + } + viewer := loginAs(t, env.engine, "refund-viewer", "viewer-pass-123") + if w := apiDo(env.engine, http.MethodGet, "/api/pay/refunds", "", viewer); w.Code != http.StatusForbidden { + t.Fatalf("viewer refund list: got %d, want 403", w.Code) + } + if w := apiDo(env.engine, http.MethodPost, "/api/pay/refunds/PO1/approve", "{}", viewer); w.Code != http.StatusForbidden { + t.Fatalf("viewer approve: got %d, want 403", w.Code) + } + if len(up.recorded()) != 0 { + t.Fatal("a rejected viewer must never reach the upstream") + } +} + +func contains(values []string, want string) bool { + for _, v := range values { + if v == want { + return true + } + } + return false +} diff --git a/app-go/app-admin/internal/router/router.go b/app-go/app-admin/internal/router/router.go index 72b49bb2..1fcf6e56 100644 --- a/app-go/app-admin/internal/router/router.go +++ b/app-go/app-admin/internal/router/router.go @@ -1,84 +1,147 @@ -// Package router wires the Gin engine and HTTP handlers for the share- -// moderation admin console (plan/1.0.16 section 5). No SSR pages: JSON API -// only + /health, matches the frontend-admin SPA contract. +// Package router wires the Gin engine and HTTP handlers for the unified admin +// console: share moderation + payment operations + console +// account management (docs/app-admin.md). No SSR pages: +// JSON API only + /health, matches the frontend-admin SPA contract. // // File layout (one concern per file): // -// router.go - engine assembly / health / middleware helpers -// auth.go - login / logout / me / password + session gate + throttle -// users.go - read-only user list / detail -// shares.go - share list / detail / ban / unban / approve (role gate) -// audit.go - audit ledger list -// sandbox.go - sandbox console proxy (reads viewer+, cancel/release admin) +// router.go - engine assembly / Deps / health / middleware helpers +// auth.go - login / logout / me / password + session gate + throttle +// users.go - read-only business user list / detail +// shares.go - share list / detail / ban / unban / approve (role gate) +// audit.go - audit ledger list +// sandbox.go - sandbox console proxy (reads viewer+, cancel/release admin) +// pay.go - payment queries / grant / refund review (/api/pay/**) +// console_users.go - console account management (/api/console/users) package router import ( "fmt" "log/slog" + "net" "net/http" "strings" "app-admin/internal/config" "app-admin/internal/db" "app-admin/internal/logger" + "app-admin/internal/payops" "app-admin/internal/service" "github.com/gin-gonic/gin" ) +// Deps bundles every service the handlers need. Optional planes (sandbox, pay) +// may be nil: their routes then answer 503 while the rest of the console keeps +// working (fail closed per plane, never fail the process). +type Deps struct { + Auth *service.AuthService + Users *service.UserService + Shares *service.ShareService + Audits *service.AuditService + Sandbox *service.SandboxService + // Pay is non-nil ONLY when pay_rdbms.url is configured AND the second GORM + // connection initialized. nil makes every /api/pay/** route answer + // 503 {"detail":"支付管理未配置"} after login. + Pay *service.PayQueryService + Grants *service.PayGrantService + // Console is the admin_user account-management service (always available — + // it does not touch the payment database). + Console *service.ConsoleUserService + // Ops is the app-pay internal client backing the refund review workbench. + Ops *payops.Client + // PayAudit is the fund-operation JSONL sink (pay-audit.jsonl hash chain). + // Shared with Grants; nil when the pay module is disabled. + PayAudit *service.PayAudit +} + // Router carries the service dependencies for all handlers. type Router struct { - cfg *config.Config - auth *service.AuthService - users *service.UserService - shares *service.ShareService - audits *service.AuditService - sandbox *service.SandboxService + cfg *config.Config + // trustedProxies are the reverse proxies whose X-Forwarded-* headers may be + // believed (empty = trust none, use the TCP peer address). + trustedProxies []*net.IPNet + auth *service.AuthService + users *service.UserService + shares *service.ShareService + audits *service.AuditService + sandbox *service.SandboxService + pay *service.PayQueryService + grants *service.PayGrantService + console *service.ConsoleUserService + ops *payops.Client + payAudit *service.PayAudit } // New builds the Gin engine: strict proxies, structured access log, security // headers, optional CORS whitelist, /health + /api/*. // -// sandboxSvc is optional: a nil (or disabled) sandbox proxy keeps every other -// console page working and answers /api/sandbox/* with 503. -func New(cfg *config.Config, auth *service.AuthService, users *service.UserService, - shares *service.ShareService, audits *service.AuditService, - sandboxSvc *service.SandboxService) *gin.Engine { +// A nil Deps field falls back to the disabled/no-op service so tests and +// degraded deployments can omit optional planes without nil dereferences. +func New(cfg *config.Config, deps Deps) *gin.Engine { if !cfg.App.Debug { gin.SetMode(gin.ReleaseMode) } - if sandboxSvc == nil { - // Defensive default: tests may omit this optional dependency; the routes - // then answer 503 instead of dereferencing nil. - sandboxSvc = service.NewSandboxService(nil) + // Defensive defaults for omitted dependencies (tests may build a router + // with only the plane they exercise; routes then answer 503/401 instead of + // panicking on nil). + if deps.Auth == nil { + deps.Auth = service.NewAuthService(0) + } + if deps.Users == nil { + deps.Users = service.NewUserService() + } + if deps.Shares == nil { + deps.Shares = service.NewShareService(nil) + } + if deps.Audits == nil { + deps.Audits = service.NewAuditService() + } + if deps.Sandbox == nil { + deps.Sandbox = service.NewSandboxService(nil) } + if deps.Console == nil { + deps.Console = service.NewConsoleUserService() + } + + // Proxy trust is explicit: gin's default (trust everything) would let a + // client spoof X-Forwarded-For to sidestep the login throttle / logging. + // The configured proxy list is parsed here once; an invalid entry is a + // warning and turns trust OFF (fail closed) instead of failing startup. + trustedProxies, err := parseTrustedProxies(cfg.Security.TrustedProxies) + if err != nil { + slog.Warn("[SECURITY] invalid trusted_proxies entry — forwarded headers will NOT be trusted", "err", err) + trustedProxies = nil + } + e := gin.New() - // Trust no proxy: gin's default (trust everything) lets clients spoof - // X-Forwarded-For to sidestep the login throttle / ClientIP logging. With - // no trusted proxy the peer address is always used. _ = e.SetTrustedProxies(nil) e.Use(logger.GinLogger()) e.Use(logger.GinRecovery()) e.Use(securityHeaders()) e.Use(ipAllowlistMiddleware(cfg.Security.IPAllowlist)) - e.Use(corsMiddleware(cfg.Security.CORS.AllowOrigins)) + e.Use(corsMiddleware(cfg.Security.CORS.AllowOrigins, trustedProxies)) - r := &Router{cfg: cfg, auth: auth, users: users, shares: shares, audits: audits, sandbox: sandboxSvc} + r := &Router{ + cfg: cfg, + trustedProxies: trustedProxies, + auth: deps.Auth, + users: deps.Users, + shares: deps.Shares, + audits: deps.Audits, + sandbox: deps.Sandbox, + pay: deps.Pay, + grants: deps.Grants, + console: deps.Console, + ops: deps.Ops, + payAudit: deps.PayAudit, + } r.registerRoutes(e) return e } func (r *Router) registerRoutes(e *gin.Engine) { - // /health truthfully probes the DB (SELECT 1 via sql.Ping); 503 when the - // shared MySQL is unreachable so orchestrators do not route to a console - // that cannot moderate anything. - e.GET("/health", func(c *gin.Context) { - if err := db.Ping(); err != nil { - detail(c, http.StatusServiceUnavailable, "database unavailable") - return - } - c.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "app-admin"}) - }) + e.GET("/health", r.health) // Login sits OUTSIDE the gate (it IS the credential check); logout // authenticates with the very session it invalidates. @@ -108,16 +171,54 @@ func (r *Router) registerRoutes(e *gin.Engine) { // execution / release lease are admin-only. app-sandbox stays the single // source of truth for sandbox state; app-admin only gates, proxies and audits. r.registerSandboxRoutes(api) + + // Payment operations: /api/pay/** is gated on the + // second connection; refund review additionally hides itself (404) unless + // the ops key/switch are wired. + r.registerPayRoutes(api) + + // Console account management: admin_user accounts, admin + // only, independent of the payment database. + r.registerConsoleUserRoutes(api) +} + +// health truthfully probes the databases: +// - main DB down -> 503 {"detail":"database unavailable"} +// - pay_rdbms.url set but PayDB down -> 503 {"detail":"pay database unavailable"} +// - otherwise -> 200 {status, service, pay:"ok"|"disabled"} +// +// The pay module being intentionally unconfigured is NOT an outage: the rest +// of the console keeps serving with pay:"disabled". +func (r *Router) health(c *gin.Context) { + if err := db.Ping(); err != nil { + detail(c, http.StatusServiceUnavailable, "database unavailable") + return + } + pay := "disabled" + if db.PayAvailable() { + if err := db.PayPing(); err != nil { + detail(c, http.StatusServiceUnavailable, "pay database unavailable") + return + } + pay = "ok" + } else if strings.TrimSpace(r.cfg.PayRDBMS.URL) != "" { + // Configured but the connection never came up (init failed / dropped). + detail(c, http.StatusServiceUnavailable, "pay database unavailable") + return + } + c.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "app-admin", "pay": pay}) } // securityHeaders adds baseline admin-console hardening: no framing, no MIME -// sniffing, no referrer leakage, no-store on JSON so moderation data never -// lands in shared caches. +// sniffing, no referrer leakage, no cross-origin embedding of console +// responses, no-store on JSON so moderation data never lands in shared caches. func securityHeaders() gin.HandlerFunc { return func(c *gin.Context) { c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Frame-Options", "DENY") c.Header("Content-Security-Policy", "frame-ancestors 'none'") + c.Header("Cross-Origin-Resource-Policy", "same-origin") + c.Header("Cross-Origin-Opener-Policy", "same-origin") c.Header("Referrer-Policy", "no-referrer") if strings.HasPrefix(c.Request.URL.Path, "/api/") { c.Header("Cache-Control", "no-store") diff --git a/app-go/app-admin/internal/router/router_test.go b/app-go/app-admin/internal/router/router_test.go index 0210dd4f..dfb1645f 100644 --- a/app-go/app-admin/internal/router/router_test.go +++ b/app-go/app-admin/internal/router/router_test.go @@ -1,6 +1,6 @@ // Router guard tests: the role gate must keep moderation writes admin-only, // /health must fail closed when the DB is unreachable, and every failure must -// speak the {"detail": ...} error contract (plan/1.0.16). +// speak the {"detail": ...} error contract. package router import ( @@ -16,12 +16,13 @@ import ( func newTestRouter() *gin.Engine { cfg := &config.Config{Security: config.SecurityConfig{}} - return New(cfg, - service.NewAuthService(0), - service.NewUserService(), - service.NewShareService(nil), - service.NewAuditService(), - service.NewSandboxService(nil)) + return New(cfg, Deps{ + Auth: service.NewAuthService(0), + Users: service.NewUserService(), + Shares: service.NewShareService(nil), + Audits: service.NewAuditService(), + Sandbox: service.NewSandboxService(nil), + }) } func hit(e *gin.Engine, path string) (int, string) { @@ -60,12 +61,13 @@ func TestAPI401WithoutCookie(t *testing.T) { func TestIPAllowlistUsesPeerAddressNotForwardedHeader(t *testing.T) { cfg := &config.Config{} cfg.Security.IPAllowlist = []string{"192.0.2.0/24", "2001:db8::1"} - e := New(cfg, - service.NewAuthService(0), - service.NewUserService(), - service.NewShareService(nil), - service.NewAuditService(), - service.NewSandboxService(nil)) + e := New(cfg, Deps{ + Auth: service.NewAuthService(0), + Users: service.NewUserService(), + Shares: service.NewShareService(nil), + Audits: service.NewAuditService(), + Sandbox: service.NewSandboxService(nil), + }) request := func(remoteAddr, forwardedFor string) int { rec := httptest.NewRecorder() @@ -92,12 +94,13 @@ func TestIPAllowlistUsesPeerAddressNotForwardedHeader(t *testing.T) { func TestOriginGateAllowsSameOriginAndConfiguredSPAOnly(t *testing.T) { cfg := &config.Config{} cfg.Security.CORS.AllowOrigins = []string{"https://admin-ui.example"} - e := New(cfg, - service.NewAuthService(0), - service.NewUserService(), - service.NewShareService(nil), - service.NewAuditService(), - service.NewSandboxService(nil)) + e := New(cfg, Deps{ + Auth: service.NewAuthService(0), + Users: service.NewUserService(), + Shares: service.NewShareService(nil), + Audits: service.NewAuditService(), + Sandbox: service.NewSandboxService(nil), + }) request := func(host, method, origin string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() @@ -128,12 +131,13 @@ func TestOriginGateAllowsSameOriginAndConfiguredSPAOnly(t *testing.T) { func TestOriginGateValidatesPreflight(t *testing.T) { cfg := &config.Config{} cfg.Security.CORS.AllowOrigins = []string{"https://admin-ui.example"} - e := New(cfg, - service.NewAuthService(0), - service.NewUserService(), - service.NewShareService(nil), - service.NewAuditService(), - service.NewSandboxService(nil)) + e := New(cfg, Deps{ + Auth: service.NewAuthService(0), + Users: service.NewUserService(), + Shares: service.NewShareService(nil), + Audits: service.NewAuditService(), + Sandbox: service.NewSandboxService(nil), + }) request := func(method, headers string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() @@ -148,7 +152,12 @@ func TestOriginGateValidatesPreflight(t *testing.T) { if rec := request("POST", "content-type"); rec.Code != http.StatusNoContent { t.Fatalf("valid preflight should be accepted, got %d body=%s", rec.Code, rec.Body.String()) } - if rec := request("DELETE", "content-type"); rec.Code != http.StatusForbidden { + // DELETE is allowed since the console account-management endpoints + // (DELETE /api/console/users/:id) were merged in; PUT stays unsupported. + if rec := request("DELETE", "content-type"); rec.Code != http.StatusNoContent { + t.Fatalf("DELETE preflight must be accepted for account management, got %d", rec.Code) + } + if rec := request("PUT", "content-type"); rec.Code != http.StatusForbidden { t.Fatalf("unsupported preflight method must be rejected, got %d", rec.Code) } if rec := request("POST", "x-evil-header"); rec.Code != http.StatusForbidden { diff --git a/app-go/app-admin/internal/router/security.go b/app-go/app-admin/internal/router/security.go index d178d930..df302f12 100644 --- a/app-go/app-admin/internal/router/security.go +++ b/app-go/app-admin/internal/router/security.go @@ -9,10 +9,11 @@ import ( "github.com/gin-gonic/gin" ) -// ipAllowlistMiddleware gates the admin surface by the TCP peer address (or -// Gin's derived client address; trusted proxies are disabled in New). Health -// probes are exempt so container / cluster probes keep working. An empty list -// leaves network policy to loopback binding, firewall, VPN, or the cluster. +// ipAllowlistMiddleware gates the admin surface by the TCP peer address. +// Forwarded headers are deliberately ignored here: a spoofed X-Forwarded-For +// must never widen the network allowlist. Health probes are exempt so container +// / cluster probes keep working. An empty list leaves network policy to +// loopback binding, firewall, VPN, or the cluster. func ipAllowlistMiddleware(entries []string) gin.HandlerFunc { networks, parseErr := parseIPAllowlist(entries) return func(c *gin.Context) { @@ -25,19 +26,30 @@ func ipAllowlistMiddleware(entries []string) gin.HandlerFunc { c.Abort() return } - ip := net.ParseIP(c.ClientIP()) - for _, network := range networks { - if ip != nil && network.Contains(ip) { - c.Next() - return - } + ip := net.ParseIP(peerIP(c)) + if ip == nil || !ipInNetworks(ip, networks) { + detail(c, http.StatusForbidden, "client IP not allowed") + c.Abort() + return } - detail(c, http.StatusForbidden, "client IP not allowed") - c.Abort() + c.Next() } } +// parseIPAllowlist turns the allowlist into networks (a bare IP becomes a /32 +// or /128). func parseIPAllowlist(entries []string) ([]*net.IPNet, error) { + return parseIPNetworks(entries) +} + +// parseTrustedProxies turns the configured reverse-proxy list into networks. +// An empty list means no proxy is trusted, so every forwarded header is +// ignored and the TCP peer address is the client address. +func parseTrustedProxies(entries []string) ([]*net.IPNet, error) { + return parseIPNetworks(entries) +} + +func parseIPNetworks(entries []string) ([]*net.IPNet, error) { networks := make([]*net.IPNet, 0, len(entries)) for _, entry := range entries { entry = strings.TrimSpace(entry) @@ -58,11 +70,125 @@ func parseIPAllowlist(entries []string) ([]*net.IPNet, error) { return networks, nil } +// peerIP is the TCP peer address with forwarded headers deliberately ignored. +// A RemoteAddr without a port is returned unchanged so direct test requests and +// unusual transports still work. +func peerIP(c *gin.Context) string { + host, _, err := net.SplitHostPort(c.Request.RemoteAddr) + if err != nil { + return c.Request.RemoteAddr + } + return host +} + +func ipInNetworks(ip net.IP, networks []*net.IPNet) bool { + for _, network := range networks { + if network.Contains(ip) { + return true + } + } + return false +} + +// trustedPeer reports whether the request came from one of the configured +// reverse proxies. +func trustedPeer(c *gin.Context, trusted []*net.IPNet) bool { + if len(trusted) == 0 { + return false + } + ip := net.ParseIP(peerIP(c)) + return ip != nil && ipInNetworks(ip, trusted) +} + +// clientIP resolves the real client address. Forwarded headers are only read +// when the TCP peer is a trusted proxy; the right-most non-trusted +// X-Forwarded-For entry is the address that entered the proxy chain, so +// anything a client prepends itself is skipped. Without a usable XFF the +// X-Real-IP header is tried, and the peer address is the final fallback. +func clientIP(c *gin.Context, trusted []*net.IPNet) string { + peer := peerIP(c) + if !trustedPeer(c, trusted) { + return peer + } + if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + for i := len(parts) - 1; i >= 0; i-- { + candidate := net.ParseIP(strings.TrimSpace(parts[i])) + if candidate == nil { + continue + } + if ipInNetworks(candidate, trusted) { + continue + } + return candidate.String() + } + } + if realIP := net.ParseIP(strings.TrimSpace(c.GetHeader("X-Real-IP"))); realIP != nil { + return realIP.String() + } + return peer +} + +// forwardedProto returns the first X-Forwarded-Proto value when it is http or +// https. +func forwardedProto(c *gin.Context) string { + value := c.GetHeader("X-Forwarded-Proto") + if i := strings.IndexByte(value, ','); i >= 0 { + value = value[:i] + } + switch proto := strings.ToLower(strings.TrimSpace(value)); proto { + case "http", "https": + return proto + } + return "" +} + +// requestOrigin reconstructs the origin the browser actually used. Behind a +// trusted proxy the forwarded scheme + host win; otherwise the transport and +// Host header are authoritative. Returns "" when the result is not a valid +// origin. +func requestOrigin(c *gin.Context, trusted []*net.IPNet) string { + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + host := c.Request.Host + if trustedPeer(c, trusted) { + if proto := forwardedProto(c); proto != "" { + scheme = proto + } + if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" { + if i := strings.IndexByte(forwardedHost, ','); i >= 0 { + forwardedHost = forwardedHost[:i] + } + if forwardedHost = strings.TrimSpace(forwardedHost); forwardedHost != "" { + host = forwardedHost + } + } + } + normalized, ok := normalizeOrigin(scheme + "://" + host) + if !ok { + return "" + } + return normalized +} + +// secureCookie reports whether the session cookie may carry the Secure flag: +// either this process terminated TLS, or a trusted proxy says the browser used +// HTTPS. A forwarded header from an untrusted peer proves nothing. +func secureCookie(c *gin.Context, trusted []*net.IPNet) bool { + if c.Request.TLS != nil { + return true + } + return trustedPeer(c, trusted) && forwardedProto(c) == "https" +} + // corsMiddleware is also the browser CSRF origin gate. Cross-origin requests // require an exact configured origin; same-origin requests are allowed. Unsafe // methods require an Origin header, so cross-site form/fetch writes cannot rely -// only on the SameSite cookie attribute. -func corsMiddleware(allowOrigins []string) gin.HandlerFunc { +// only on the SameSite cookie attribute. Behind a trusted proxy the origin is +// compared against the forwarded scheme/host, matching what the browser sees. +func corsMiddleware(allowOrigins []string, trusted []*net.IPNet) gin.HandlerFunc { allowed := make(map[string]bool, len(allowOrigins)) for _, origin := range allowOrigins { if normalized, ok := normalizeOrigin(origin); ok { @@ -89,7 +215,7 @@ func corsMiddleware(allowOrigins []string) gin.HandlerFunc { c.Abort() return } - sameOrigin := normalized == requestOrigin(c) + sameOrigin := normalized == requestOrigin(c, trusted) if !sameOrigin && !allowed[normalized] { detail(c, http.StatusForbidden, "origin not allowed") c.Abort() @@ -115,18 +241,6 @@ func corsMiddleware(allowOrigins []string) gin.HandlerFunc { } } -func requestOrigin(c *gin.Context) string { - scheme := "http" - if c.Request.TLS != nil { - scheme = "https" - } - normalized, ok := normalizeOrigin(scheme + "://" + c.Request.Host) - if !ok { - return "" - } - return normalized -} - func normalizeOrigin(value string) (string, bool) { u, err := url.Parse(value) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || @@ -148,13 +262,13 @@ func isUnsafeMethod(method string) bool { func validPreflight(c *gin.Context) bool { requestedMethod := strings.ToUpper(strings.TrimSpace(c.GetHeader("Access-Control-Request-Method"))) switch requestedMethod { - case "GET", "POST": + case "GET", "POST", "DELETE": // DELETE: /api/console/users/:id default: return false } for _, header := range strings.Split(c.GetHeader("Access-Control-Request-Headers"), ",") { switch strings.ToLower(strings.TrimSpace(header)) { - case "", "authorization", "content-type", "x-request-id": + case "", "authorization", "content-type", "x-request-id", "x-device-id": default: return false } @@ -165,8 +279,8 @@ func validPreflight(c *gin.Context) bool { func setCORSHeaders(c *gin.Context, origin string) { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id") + c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id, X-Device-Id") c.Header("Access-Control-Max-Age", "600") c.Header("Vary", "Origin") } diff --git a/app-go/app-admin/internal/router/security_test.go b/app-go/app-admin/internal/router/security_test.go new file mode 100644 index 00000000..c7125ab4 --- /dev/null +++ b/app-go/app-admin/internal/router/security_test.go @@ -0,0 +1,344 @@ +package router + +// Login security tests: device fingerprint binding, session IP binding +// (exact / subnet), trusted-proxy client address resolution, the CORS header +// whitelist / hardening headers, and the login audit trail (failed attempt, new +// device, new address). Everything runs against in-memory SQLite with the +// shared console test harness. + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "app-admin/internal/config" + "app-admin/internal/db" + "app-admin/internal/model" + "app-admin/internal/repo" + "app-admin/internal/service" + + "github.com/gin-gonic/gin" +) + +const testAdminCredentials = `{"username":"admin","password":"app-admin"}` + +// newSecurityTestEnv seeds the console admin account and returns a router with +// the supplied session / security policy. +func newSecurityTestEnv(t *testing.T, cfg *config.Config) *gin.Engine { + t.Helper() + setupTestDBs(t) + if err := repo.EnsureSeedAdmin("admin", "app-admin"); err != nil { + t.Fatalf("seed admin: %v", err) + } + if cfg == nil { + cfg = &config.Config{} + } + return New(cfg, Deps{ + Auth: service.NewAuthServiceWithLimits(0, service.LoginLimits{}), + Console: service.NewConsoleUserService(), + }) +} + +// secDo performs one console request with an explicit TCP peer address and +// optional session cookie. Unsafe methods carry a same-origin Origin because the +// CSRF gate runs before the session gate. +func secDo(e http.Handler, method, path, body, peer string, headers map[string]string, cookie string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if peer != "" { + req.RemoteAddr = peer + } + if method != http.MethodGet && method != http.MethodHead { + req.Header.Set("Origin", "http://"+req.Host) + } + for k, v := range headers { + req.Header.Set(k, v) + } + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func loginAttempt(e http.Handler, peer string, headers map[string]string) (int, string) { + rec := secDo(e, http.MethodPost, "/api/login", testAdminCredentials, peer, headers, "") + return rec.Code, sessionCookieOf(rec) +} + +func mustLogin(t *testing.T, e http.Handler, peer string, headers map[string]string) string { + t.Helper() + code, token := loginAttempt(e, peer, headers) + if code != http.StatusOK || token == "" { + t.Fatalf("login: status %d, token %q", code, token) + } + return token +} + +func getMe(e http.Handler, peer string, headers map[string]string, token string) *httptest.ResponseRecorder { + return secDo(e, http.MethodGet, "/api/me", "", peer, headers, "admin_session="+token) +} + +func auditCount(t *testing.T, action string) int64 { + t.Helper() + var n int64 + if err := db.DB.Model(&model.AdminAudit{}).Where("action = ?", action).Count(&n).Error; err != nil { + t.Fatalf("count %s audits: %v", action, err) + } + return n +} + +func auditRows(t *testing.T, action string) []model.AdminAudit { + t.Helper() + var rows []model.AdminAudit + if err := db.DB.Where("action = ?", action).Find(&rows).Error; err != nil { + t.Fatalf("load %s audits: %v", action, err) + } + return rows +} + +// Device binding: a session is only valid for the device that created it. +func TestDeviceBindingStrict(t *testing.T) { + cfg := &config.Config{} + cfg.Session.DeviceBinding = "strict" + e := newSecurityTestEnv(t, cfg) + deviceA := "device-a-0123456789abcdef" + deviceB := "device-b-0123456789abcdef" + headersA := map[string]string{"X-Device-Id": deviceA} + headersB := map[string]string{"X-Device-Id": deviceB} + + // Missing / malformed header is a client error, not a credential guess. + for _, headers := range []map[string]string{nil, {"X-Device-Id": "short"}, {"X-Device-Id": strings.Repeat("!", 20)}} { + rec := secDo(e, http.MethodPost, "/api/login", testAdminCredentials, "", headers, "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("missing/invalid device header: got %d body %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "X-Device-Id header required") { + t.Fatalf("device header detail: %s", rec.Body.String()) + } + } + // Rejected logins never burn throttle budget: a valid login still succeeds. + token := mustLogin(t, e, "", headersA) + if rec := getMe(e, "", headersA, token); rec.Code != http.StatusOK { + t.Fatalf("same device must pass: %d %s", rec.Code, rec.Body.String()) + } + + // Another device presenting the cookie is rejected and the row is deleted. + if rec := getMe(e, "", headersB, token); rec.Code != http.StatusUnauthorized || + rec.Body.String() != `{"detail":"login required"}` { + t.Fatalf("other device must be rejected: %d %s", rec.Code, rec.Body.String()) + } + if bundle, _ := repo.GetSession(token); bundle != nil { + t.Fatal("device-mismatch session must be deleted") + } + rows := auditRows(t, "session_device_mismatch") + if len(rows) != 1 || rows[0].TargetType == nil || *rows[0].TargetType != "session" || + rows[0].Detail == nil || !strings.Contains(*rows[0].Detail, "device=") { + t.Fatalf("device mismatch audit: %+v", rows) + } + + // A fresh login from the second device works. + tokenB := mustLogin(t, e, "", headersB) + if rec := getMe(e, "", headersB, tokenB); rec.Code != http.StatusOK { + t.Fatalf("re-login on the second device must work: %d", rec.Code) + } +} + +// Session IP binding: off-by-policy changes invalidate the session. +func TestSessionIPBindingExactAndSubnet(t *testing.T) { + // exact: any address change invalidates. + cfg := &config.Config{} + cfg.Session.IPBinding = "exact" + e := newSecurityTestEnv(t, cfg) + token := mustLogin(t, e, "192.0.2.10:1234", nil) + if rec := getMe(e, "192.0.2.10:5555", nil, token); rec.Code != http.StatusOK { + t.Fatalf("same address must pass: %d %s", rec.Code, rec.Body.String()) + } + if rec := getMe(e, "192.0.2.11:1234", nil, token); rec.Code != http.StatusUnauthorized { + t.Fatalf("different address must be rejected under exact binding: %d", rec.Code) + } + if bundle, _ := repo.GetSession(token); bundle != nil { + t.Fatal("IP-mismatch session must be deleted") + } + rows := auditRows(t, "session_ip_mismatch") + if len(rows) != 1 || rows[0].TargetType == nil || *rows[0].TargetType != "session" { + t.Fatalf("IP mismatch audit: %+v", rows) + } + + // subnet: same /24 passes (the sliding request does not end the session), + // a different /24 fails. + subnetCfg := &config.Config{} + subnetCfg.Session.IPBinding = "subnet" + subnetEngine := newSecurityTestEnv(t, subnetCfg) + subnetToken := mustLogin(t, subnetEngine, "198.51.100.10:1234", nil) + if rec := getMe(subnetEngine, "198.51.100.11:9999", nil, subnetToken); rec.Code != http.StatusOK { + t.Fatalf("same /24 must pass under subnet binding: %d %s", rec.Code, rec.Body.String()) + } + if rec := getMe(subnetEngine, "198.51.100.250:9999", nil, subnetToken); rec.Code != http.StatusOK { + t.Fatalf("same /24 (different host) must pass: %d", rec.Code) + } + if rec := getMe(subnetEngine, "198.51.101.10:1234", nil, subnetToken); rec.Code != http.StatusUnauthorized { + t.Fatalf("another /24 must be rejected under subnet binding: %d", rec.Code) + } + + // A configured wider prefix is honoured (/16 instead of the default /24). + wideCfg := &config.Config{} + wideCfg.Session.IPBinding = "subnet" + wideCfg.Session.IPv4Prefix = 16 + wideEngine := newSecurityTestEnv(t, wideCfg) + wideToken := mustLogin(t, wideEngine, "172.20.1.10:1234", nil) + if rec := getMe(wideEngine, "172.20.2.10:1234", nil, wideToken); rec.Code != http.StatusOK { + t.Fatalf("same /16 must pass with ipv4_prefix=16: %d %s", rec.Code, rec.Body.String()) + } +} + +// Trusted proxies: only a trusted peer may influence the client address, and +// the right-most non-trusted X-Forwarded-For entry wins. +func TestTrustedProxyResolvesClientIP(t *testing.T) { + cfg := &config.Config{} + cfg.Session.IPBinding = "exact" + cfg.Security.TrustedProxies = []string{"172.16.0.0/12"} + e := newSecurityTestEnv(t, cfg) + + forwarded := map[string]string{"X-Forwarded-For": "203.0.113.9"} + token := mustLogin(t, e, "172.16.0.2:1234", forwarded) + bundle, err := repo.GetSession(token) + if err != nil || bundle == nil || bundle.Session.IP == nil || *bundle.Session.IP != "203.0.113.9" { + t.Fatalf("session must store the forwarded client address: %+v err=%v", bundle, err) + } + // A client-prepended entry is skipped: the right-most non-trusted address is + // the one that entered the proxy chain. + spoofedPrefix := map[string]string{"X-Forwarded-For": "6.6.6.6, 203.0.113.9"} + if rec := getMe(e, "172.16.0.2:1234", spoofedPrefix, token); rec.Code != http.StatusOK { + t.Fatalf("forwarded client address must match: %d %s", rec.Code, rec.Body.String()) + } + // A different forwarded client address breaks the exact binding. + if rec := getMe(e, "172.16.0.2:1234", map[string]string{"X-Forwarded-For": "203.0.113.10"}, token); rec.Code != http.StatusUnauthorized { + t.Fatalf("changed forwarded address must be rejected: %d", rec.Code) + } + + // X-Real-IP is the fallback when X-Forwarded-For is absent. + realIPToken := mustLogin(t, e, "172.16.0.2:1234", map[string]string{"X-Real-IP": "203.0.113.20"}) + if bundle, _ := repo.GetSession(realIPToken); bundle == nil || bundle.Session.IP == nil || + *bundle.Session.IP != "203.0.113.20" { + t.Fatalf("X-Real-IP fallback not applied: %+v", bundle) + } + + // An untrusted peer cannot spoof its address: the peer itself is used. + directToken := mustLogin(t, e, "198.51.100.5:1234", forwarded) + if bundle, _ := repo.GetSession(directToken); bundle == nil || bundle.Session.IP == nil || + *bundle.Session.IP != "198.51.100.5" { + t.Fatalf("untrusted peer must be its own client address: %+v", bundle) + } + // Rotating the forged header on the same direct connection stays valid. + if rec := getMe(e, "198.51.100.5:1234", map[string]string{"X-Forwarded-For": "203.0.113.11"}, directToken); rec.Code != http.StatusOK { + t.Fatalf("forged header from an untrusted peer must be ignored: %d %s", rec.Code, rec.Body.String()) + } +} + +// CORS: the device header must be listed, Origin: null stays forbidden, and the +// hardening headers are present. +func TestCORSAllowsDeviceHeaderAndRejectsNullOrigin(t *testing.T) { + cfg := &config.Config{} + cfg.Security.CORS.AllowOrigins = []string{"https://admin-ui.example"} + e := newSecurityTestEnv(t, cfg) + + preflight := func(origin, method, headers string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodOptions, "/api/login", nil) + req.Header.Set("Origin", origin) + req.Header.Set("Access-Control-Request-Method", method) + req.Header.Set("Access-Control-Request-Headers", headers) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec + } + + rec := preflight("https://admin-ui.example", "POST", "content-type, x-device-id") + if rec.Code != http.StatusNoContent { + t.Fatalf("preflight with x-device-id must be accepted: %d %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Device-Id") { + t.Fatalf("Access-Control-Allow-Headers must list X-Device-Id: %q", got) + } + if rec := preflight("null", "POST", "content-type"); rec.Code != http.StatusForbidden { + t.Fatalf("Origin: null must be rejected: %d", rec.Code) + } + if rec := preflight("https://attacker.example", "POST", "content-type"); rec.Code != http.StatusForbidden { + t.Fatalf("unconfigured origin must be rejected: %d", rec.Code) + } + + health := secDo(e, http.MethodGet, "/health", "", "", nil, "") + if got := health.Header().Get("Cross-Origin-Resource-Policy"); got != "same-origin" { + t.Fatalf("Cross-Origin-Resource-Policy = %q", got) + } + if got := health.Header().Get("Cross-Origin-Opener-Policy"); got != "same-origin" { + t.Fatalf("Cross-Origin-Opener-Policy = %q", got) + } + if got := health.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q", got) + } +} + +// Login audit trail: failures are recorded, and a device that never signed in +// before raises exactly one new-device warning (the very first login does not). +func TestLoginAuditsFailedAndNewDevice(t *testing.T) { + cfg := &config.Config{} + cfg.Session.DeviceBinding = "strict" + e := newSecurityTestEnv(t, cfg) + headersA := map[string]string{"X-Device-Id": "device-a-0123456789abcdef"} + headersB := map[string]string{"X-Device-Id": "device-b-0123456789abcdef"} + + rec := secDo(e, http.MethodPost, "/api/login", `{"username":"admin","password":"wrong-password"}`, "", headersA, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong password must be 401, got %d", rec.Code) + } + if got := auditCount(t, "login_failed"); got != 1 { + t.Fatalf("failed login must be audited once, got %d", got) + } + failed := auditRows(t, "login_failed") + if failed[0].AdminName != "admin" || failed[0].Detail == nil || + !strings.Contains(*failed[0].Detail, "reason=invalid_credentials") || + !strings.Contains(*failed[0].Detail, "device=") { + t.Fatalf("failed login audit shape: %+v", failed[0]) + } + + // First successful login: the account has no login history yet, so no + // first-sighting warning is emitted. + mustLogin(t, e, "", headersA) + if got := auditCount(t, "login_new_device"); got != 0 { + t.Fatalf("first login must not raise a new-device warning, got %d", got) + } + if got := auditCount(t, "login_new_ip"); got != 0 { + t.Fatalf("first login must not raise a new-IP warning, got %d", got) + } + + // Same account, different device: exactly one new-device warning. + mustLogin(t, e, "", headersB) + if got := auditCount(t, "login_new_device"); got != 1 { + t.Fatalf("second device must raise one new-device warning, got %d", got) + } + // Repeating the same device does not duplicate the warning. + mustLogin(t, e, "", headersB) + if got := auditCount(t, "login_new_device"); got != 1 { + t.Fatalf("known device must not repeat the warning, got %d", got) + } + // Every login so far came from the same client address, so no new-IP + // warning was raised. + if got := auditCount(t, "login_new_ip"); got != 0 { + t.Fatalf("same address must not raise a new-IP warning, got %d", got) + } + + // A login from a different address raises exactly one new-IP warning while + // the known device stays quiet. + mustLogin(t, e, "203.0.113.50:1234", headersB) + if got := auditCount(t, "login_new_ip"); got != 1 { + t.Fatalf("new address must raise one new-IP warning, got %d", got) + } + if got := auditCount(t, "login_new_device"); got != 1 { + t.Fatalf("known device must not repeat the warning after an address change, got %d", got) + } +} diff --git a/app-go/app-admin/internal/service/auth.go b/app-go/app-admin/internal/service/auth.go index fdcc5cc4..66b0b829 100644 --- a/app-go/app-admin/internal/service/auth.go +++ b/app-go/app-admin/internal/service/auth.go @@ -1,14 +1,17 @@ // Console authentication: bcrypt login against admin_user, DB-backed session -// tokens (admin_session table, sliding 12h), HttpOnly cookie admin_session, -// and a 10-failures-per-minute-per-IP login throttle (same numbers as -// app-pay-admin). Role gate (viewer read-only / admin writes) is applied in -// the router; this service is the credential + session authority. +// tokens (admin_session table, sliding 12h expiry), HttpOnly cookie +// admin_session, two independent failed-login budgets (short per IP, longer per +// account) and the audit trail for every login outcome. Device / IP binding of +// live sessions is enforced by the router; this service is the credential + +// session authority. The role gate (viewer read-only / admin writes) also lives +// in the router. package service import ( "crypto/rand" "encoding/hex" "errors" + "log/slog" "strings" "sync" "time" @@ -20,10 +23,20 @@ import ( ) const ( - maxFails = 10 - failWindow = time.Minute + // Default per-IP budget: stops one host hammering the login endpoint. + defaultMaxFailsPerIP = 10 + defaultIPWindow = time.Minute + // Default per-account budget: stops credential stuffing spread over many + // addresses, so the window is intentionally much longer. + defaultMaxFailsPerAccount = 10 + defaultAccountWindow = 15 * time.Minute + defaultTTL = 12 * time.Hour sessionBytes = 32 // -> 64 hex chars, fits session_token varchar(128) + + // devicePrefixLen is how many hex chars of the device fingerprint are used + // for audit comparison/details; the full hash stays server-side. + devicePrefixLen = 12 ) var ( @@ -31,82 +44,191 @@ var ( ErrInvalidCredentials = errors.New("invalid credentials") ) -// AuthService owns login / session / password flows + the per-IP throttle. +// dummyPasswordHash equalises the credential-check timing for unknown accounts: +// without it, a missing user returns before bcrypt and leaks username existence. +var dummyPasswordHash = func() []byte { + hash, _ := bcrypt.GenerateFromPassword([]byte("invalid-credential-timing-equalizer"), bcrypt.DefaultCost) + return hash +}() + +// LoginLimits configures the two failed-login budgets. Zero (or negative) +// fields fall back to the defaults. +type LoginLimits struct { + IPMax int + IPWindowSeconds int + AccountMax int + AccountWindowSeconds int +} + +// failBudget is one independent sliding-window failure counter. All access +// happens under AuthService.mu. +type failBudget struct { + max int + window time.Duration + fails map[string]int + start time.Time +} + +func (b *failBudget) roll(now time.Time) { + if now.Sub(b.start) >= b.window { + b.fails = make(map[string]int) + b.start = now + } +} + +func (b *failBudget) allow(key string, now time.Time) bool { + b.roll(now) + return b.fails[key] < b.max +} + +func (b *failBudget) fail(key string, now time.Time) { + b.roll(now) + b.fails[key]++ +} + +func (b *failBudget) reset(key string) { delete(b.fails, key) } + +// AuthService owns login / session / password flows + both failure budgets. type AuthService struct { ttl time.Duration - mu sync.Mutex - fails map[string]int - windowStart time.Time + mu sync.Mutex + ip failBudget + account failBudget } +// NewAuthService keeps the original constructor shape and the default budgets. func NewAuthService(ttlMinutes int) *AuthService { + return NewAuthServiceWithLimits(ttlMinutes, LoginLimits{}) +} + +// NewAuthServiceWithLimits builds the service with explicit login budgets +// (zero values fall back to 10 fails / 60s per IP and 10 fails / 900s per +// account). +func NewAuthServiceWithLimits(ttlMinutes int, lim LoginLimits) *AuthService { ttl := defaultTTL if ttlMinutes > 0 { ttl = time.Duration(ttlMinutes) * time.Minute } + now := time.Now() return &AuthService{ - ttl: ttl, - fails: make(map[string]int), - windowStart: time.Now(), + ttl: ttl, + ip: failBudget{ + max: positiveOr(lim.IPMax, defaultMaxFailsPerIP), + window: secondsOr(lim.IPWindowSeconds, defaultIPWindow), + fails: make(map[string]int), + start: now, + }, + account: failBudget{ + max: positiveOr(lim.AccountMax, defaultMaxFailsPerAccount), + window: secondsOr(lim.AccountWindowSeconds, defaultAccountWindow), + fails: make(map[string]int), + start: now, + }, } } -// ── throttle ───────────────────────────────────────────────────────────── +func positiveOr(value, fallback int) int { + if value > 0 { + return value + } + return fallback +} -func (a *AuthService) rollLocked() { - now := time.Now() - if now.Sub(a.windowStart) >= failWindow { - a.fails = make(map[string]int) - a.windowStart = now +func secondsOr(value int, fallback time.Duration) time.Duration { + if value > 0 { + return time.Duration(value) * time.Second } + return fallback } -// Allow reports whether the IP still has failure budget left in the window. +// ── throttle ───────────────────────────────────────────────────────────── + +// Allow reports whether the IP still has failure budget left in its window. func (a *AuthService) Allow(ip string) bool { a.mu.Lock() defer a.mu.Unlock() - a.rollLocked() - return a.fails[ip] < maxFails + return a.ip.allow(ip, time.Now()) } // Fail counts one failed credential attempt for the IP. func (a *AuthService) Fail(ip string) { a.mu.Lock() defer a.mu.Unlock() - a.rollLocked() - a.fails[ip]++ + a.ip.fail(ip, time.Now()) } // Reset clears the IP failure counter after a success. func (a *AuthService) Reset(ip string) { a.mu.Lock() defer a.mu.Unlock() - delete(a.fails, ip) + a.ip.reset(ip) +} + +// AllowAccount reports whether the account still has failure budget left. The +// account window is independent of the IP window, so rotating addresses does +// not extend the budget of a single account. +func (a *AuthService) AllowAccount(username string) bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.account.allow(normalizeAccount(username), time.Now()) +} + +// FailAccount counts one failed credential attempt for the account. +func (a *AuthService) FailAccount(username string) { + a.mu.Lock() + defer a.mu.Unlock() + a.account.fail(normalizeAccount(username), time.Now()) +} + +// ResetAccount clears the account failure counter after a success. +func (a *AuthService) ResetAccount(username string) { + a.mu.Lock() + defer a.mu.Unlock() + a.account.reset(normalizeAccount(username)) +} + +func normalizeAccount(username string) string { + return strings.ToLower(strings.TrimSpace(username)) } // ── login / session / password ─────────────────────────────────────────── -// Login verifies credentials and issues a session. On success the failure -// counter resets, last_login_at is stamped, the DB session row is created and -// the account audit log records the login. Empty credentials are a fresh -// visitor, not an attack: they must not burn throttle budget. -func (a *AuthService) Login(username, password, ip, userAgent string) (*model.AdminUser, string, error) { - if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" { +// Login verifies credentials and issues a session. Both failure budgets are +// checked first, so a throttled caller learns nothing about the account. On +// failure both budgets advance and the attempt is audited; on success they +// reset, the session row stores the device fingerprint, last_login_at is +// stamped and the ledger records the login plus first-sighting warnings for a +// new device / address. Empty credentials are a fresh visitor, not an attack, +// and never burn budget. +func (a *AuthService) Login(username, password, ip, userAgent, deviceHash string) (*model.AdminUser, string, error) { + name := strings.TrimSpace(username) + if name == "" || strings.TrimSpace(password) == "" { return nil, "", ErrInvalidCredentials } - if !a.Allow(ip) { + if !a.Allow(ip) || !a.AllowAccount(name) { return nil, "", ErrThrottled } - user, err := repo.GetUserByUsername(username) + user, err := repo.GetUserByUsername(name) if err != nil { return nil, "", err } - if user == nil || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil { + valid := false + if user == nil { + // Burn a bcrypt comparison even for unknown usernames (timing equaliser). + _ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(password)) + } else { + valid = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil + } + if !valid { a.Fail(ip) + a.FailAccount(name) + recordAuditBestEffort(truncateRunes(name, 64), "login_failed", + "ip="+truncateStr(ip, 60)+" device="+devicePrefix(deviceHash)+" reason=invalid_credentials") return nil, "", ErrInvalidCredentials } a.Reset(ip) + a.ResetAccount(name) // Housekeeping + fresh session. if err := repo.PurgeExpiredSessions(); err != nil { @@ -117,37 +239,34 @@ func (a *AuthService) Login(username, password, ip, userAgent string) (*model.Ad return nil, "", err } now := time.Now() - if err := repo.CreateSession(token, user.ID, ip, userAgent, now.Add(a.ttl)); err != nil { + if err := repo.CreateSession(token, user.ID, ip, userAgent, deviceHash, now.Add(a.ttl)); err != nil { return nil, "", err } if err := repo.TouchLogin(user.ID); err != nil { return nil, "", err } - detail := "ip=" + truncateStr(ip, 60) - if userAgent != "" { - detail += " ua=" + truncateStr(userAgent, 100) - } - _ = repo.RecordAudit(user.Username, "login", "", "", detail) + // First-sighting warnings compare against the PREVIOUS login history, so + // they are resolved before the current login row is written; the login + // entry itself closes the sequence. + a.alertNewDeviceOrIP(user.Username, ip, deviceHash) + recordAuditBestEffort(user.Username, "login", loginDetail(ip, deviceHash, userAgent)) return user, token, nil } -// ResolveSession loads a valid session and slides its expiry forward -// (sliding-authentication: every authenticated request restarts the TTL). +// ResolveSession loads a valid session. The router applies the device / IP +// binding checks before sliding its expiry, so a rejected session is never +// renewed here. func (a *AuthService) ResolveSession(token string) (*repo.SessionBundle, error) { if token == "" { return nil, errors.New("empty session token") } - bundle, err := repo.GetSession(token) - if err != nil { - return nil, err - } - if bundle == nil { - return nil, nil - } - if err := repo.TouchSession(token, time.Now().Add(a.ttl)); err != nil { - return nil, err - } - return bundle, nil + return repo.GetSession(token) +} + +// TouchSession slides the session expiry forward (called once the device / IP +// binding checks pass, so a rejected request cannot extend its lifetime). +func (a *AuthService) TouchSession(token string) error { + return repo.TouchSession(token, time.Now().Add(a.ttl)) } // Logout deletes exactly the presented session (so only the current device @@ -164,9 +283,8 @@ func (a *AuthService) Logout(username, token, ip string) error { } // ChangePassword validates the old password, enforces a minimum length and -// clears must_change_password. Allowed for ANY logged-in account regardless -// of role (the contract explicitly keeps change-password outside the -// admin-only gate). +// clears must_change_password. Allowed for ANY logged-in account regardless of +// role: the admin-only gate covers moderation writes, not password changes. func (a *AuthService) ChangePassword(user *model.AdminUser, oldPassword, newPassword string) error { if len(newPassword) < 8 { return repo.ErrWeakPassword @@ -185,6 +303,49 @@ func (a *AuthService) TTLSeconds() int { return int(a.ttl.Seconds()) } +// loginDetail is the ledger shape shared by the history queries: the address +// and the device fingerprint prefix are always present so a later login can be +// compared against them. +func loginDetail(ip, deviceHash, userAgent string) string { + return "ip=" + truncateStr(ip, 60) + + " device=" + devicePrefix(deviceHash) + + " ua=" + truncateStr(userAgent, 100) +} + +// alertNewDeviceOrIP writes a first-sighting warning when the account already +// has login history but this device / address never appeared in it. Best +// effort: a missing warning must never block a successful login. +func (a *AuthService) alertNewDeviceOrIP(username, ip, deviceHash string) { + hasHistory, err := repo.HasAuditDetail(username, "login", "ip=") + if err != nil || !hasHistory { + return + } + detail := "ip=" + truncateStr(ip, 60) + " device=" + devicePrefix(deviceHash) + if deviceHash != "" { + seen, err := repo.HasAuditDetail(username, "login", "device="+devicePrefix(deviceHash)+" ua=") + if err == nil && !seen { + recordAuditBestEffort(username, "login_new_device", detail) + } + } + seen, err := repo.HasAuditDetail(username, "login", "ip="+truncateStr(ip, 60)+" device=") + if err == nil && !seen { + recordAuditBestEffort(username, "login_new_ip", detail) + } +} + +func recordAuditBestEffort(adminName, action, detail string) { + if err := repo.RecordAudit(adminName, action, "", "", detail); err != nil { + slog.Warn("[AUTH] audit write failed", "action", action, "err", err) + } +} + +func devicePrefix(hash string) string { + if len(hash) > devicePrefixLen { + return hash[:devicePrefixLen] + } + return hash +} + func newSessionToken() (string, error) { b := make([]byte, sessionBytes) if _, err := rand.Read(b); err != nil { diff --git a/app-go/app-admin/internal/service/auth_test.go b/app-go/app-admin/internal/service/auth_test.go index 169bac73..2519e69c 100644 --- a/app-go/app-admin/internal/service/auth_test.go +++ b/app-go/app-admin/internal/service/auth_test.go @@ -1,5 +1,5 @@ // Auth service guard tests: the login throttle (10 failed logins / minute / -// IP, plan/1.0.16 section 5) and the change-password gates. All memory-only — +// IP) and the change-password gates. All memory-only — // no DB connection involved (the length gate short-circuits before bcrypt / // repo, and a zero-value bcrypt comparison fails without touching the DB). package service @@ -32,6 +32,81 @@ func TestLoginThrottleTenFailsPerIPPerMinute(t *testing.T) { } } +// The account budget is independent of the IP budget: rotating addresses does +// not reset it, and exhausting it does not block other accounts or unused IPs. +func TestAccountThrottleIsIndependentOfIP(t *testing.T) { + a := NewAuthServiceWithLimits(0, LoginLimits{IPMax: 100, AccountMax: 3, AccountWindowSeconds: 900}) + + for i, ip := range []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"} { + if !a.AllowAccount("Admin") { + t.Fatalf("account budget exhausted too early at attempt %d", i) + } + a.Fail(ip) + a.FailAccount("Admin") + } + // The account key is normalized (trim + lower), so the same account stays + // throttled regardless of how the username is typed. + if a.AllowAccount(" admin ") { + t.Fatal("account must be throttled after 3 failures across different IPs") + } + // Other accounts and unused addresses keep their own budgets. + if !a.AllowAccount("other-admin") { + t.Fatal("account throttle must be per-account") + } + if !a.Allow("10.0.0.4") { + t.Fatal("IPs that never failed must keep their budget") + } + + // ResetAccount clears only the account budget. + a.ResetAccount("admin") + if !a.AllowAccount("admin") { + t.Fatal("ResetAccount must clear the account budget") + } + for i := 0; i < 100; i++ { + a.Fail("10.0.0.9") + } + if a.Allow("10.0.0.9") { + t.Fatal("IP budget must still stop a single address") + } +} + +// Zero / negative LoginLimits fall back to the documented defaults; explicit +// values replace them. +func TestLoginLimitsZeroValuesFallBackToDefaults(t *testing.T) { + a := NewAuthServiceWithLimits(0, LoginLimits{}) + for i := 0; i < 10; i++ { + a.Fail("192.0.2.1") + } + if a.Allow("192.0.2.1") { + t.Fatal("default IP budget must block after 10 failures") + } + for i := 0; i < 10; i++ { + a.FailAccount("admin") + } + if a.AllowAccount("admin") { + t.Fatal("default account budget must block after 10 failures") + } + + b := NewAuthServiceWithLimits(0, LoginLimits{IPMax: 2, AccountMax: 1, IPWindowSeconds: 30, AccountWindowSeconds: 60}) + b.Fail("ip") + b.Fail("ip") + if b.Allow("ip") { + t.Fatal("explicit IPMax must replace the default") + } + b.FailAccount("root") + if b.AllowAccount("ROOT") { + t.Fatal("explicit AccountMax must replace the default") + } + + c := NewAuthServiceWithLimits(0, LoginLimits{IPMax: -5, AccountMax: -1}) + for i := 0; i < 10; i++ { + c.Fail("ip") + } + if c.Allow("ip") { + t.Fatal("negative limits must fall back to the defaults") + } +} + func TestChangePasswordGatesBeforeDB(t *testing.T) { a := NewAuthService(0) user := &model.AdminUser{Username: "admin", PasswordHash: "$2a$10$0123456789012345678901u"} diff --git a/app-go/app-admin/internal/service/common.go b/app-go/app-admin/internal/service/common.go index 10aa2ed6..36ec54f3 100644 --- a/app-go/app-admin/internal/service/common.go +++ b/app-go/app-admin/internal/service/common.go @@ -1,5 +1,5 @@ // Shared service helpers: pagination window (page from 1, pageSize <= 100, -// default 20 per plan/1.0.16) and field presentation utilities used while +// default 20) and field presentation utilities used while // assembling the internal/dto response shapes. package service @@ -52,6 +52,18 @@ func rfc3339(t *time.Time) string { return t.Format(time.RFC3339) } +// rfc3339Time renders a required instant (contract: time output is RFC3339). +func rfc3339Time(t time.Time) string { return t.Format(time.RFC3339) } + +// rfc3339Ptr renders an optional instant; nil stays JSON null on the wire. +func rfc3339Ptr(t *time.Time) *string { + if t == nil || t.IsZero() { + return nil + } + s := t.Format(time.RFC3339) + return &s +} + // isActive resolves an rbac binding "in force": NULL is_active counts as // active; an expiry in the past deactivates it regardless. func isActive(flag *bool, expires *time.Time) bool { diff --git a/app-go/app-admin/internal/service/console_user.go b/app-go/app-admin/internal/service/console_user.go new file mode 100644 index 00000000..1c9d467d --- /dev/null +++ b/app-go/app-admin/internal/service/console_user.go @@ -0,0 +1,183 @@ +// Console account management: create / list / reset password / +// delete the admin_user accounts that gate the console itself. +// +// This module is INDEPENDENT of the payment database — it keeps working when +// app_pay is disabled. Business rules pinned here: +// - an admin can never delete their own account; +// - an admin can never delete the LAST admin (the console would lock itself out); +// - resetting a password forces must_change_password=true AND deletes every +// session of that account (a stolen cookie must not survive the reset); +// - every mutation writes an admin_audit row (same database, so an audit +// failure surfaces instead of silently leaving an untraceable change). +package service + +import ( + "errors" + "fmt" + "strings" + "unicode/utf8" + + "app-admin/internal/dto" + "app-admin/internal/repo" +) + +// Account-management validation / rule errors (the router maps each onto its +// contract status code: 400 validation, 409 duplicate, 404 missing). +var ( + ErrConsoleInvalidUsername = errors.New("username must be 2..64 characters") + ErrConsoleInvalidPassword = errors.New("password must be 8..128 characters") + ErrConsolePasswordTooLong = errors.New("password must be at most 72 bytes (bcrypt limit)") + ErrConsoleInvalidRole = errors.New("role must be admin or viewer") + ErrCannotDeleteSelf = errors.New("cannot delete your own account") + ErrLastAdmin = errors.New("cannot delete the last admin") +) + +// ConsoleUserService owns the account-management rules. +type ConsoleUserService struct { + record AuditRecorder +} + +// NewConsoleUserService wires the production admin_audit recorder. +func NewConsoleUserService() *ConsoleUserService { + return &ConsoleUserService{record: repo.RecordAudit} +} + +// NewConsoleUserServiceWithAudit is the test seam. +func NewConsoleUserServiceWithAudit(record AuditRecorder) *ConsoleUserService { + return &ConsoleUserService{record: record} +} + +// List returns every console account, username ASC (contract ordering). +func (s *ConsoleUserService) List() (*dto.ConsoleUserList, error) { + rows, err := repo.ListAdminUsers() + if err != nil { + return nil, err + } + items := make([]dto.ConsoleUserItem, 0, len(rows)) + for i := range rows { + items = append(items, dto.ConsoleUserItem{ + ID: rows[i].ID, + Username: rows[i].Username, + Role: rows[i].Role, + MustChangePassword: rows[i].MustChangePassword, + LastLoginAt: rfc3339Ptr(rows[i].LastLoginAt), + CreatedAt: rfc3339Ptr(rows[i].CreatedAt), + }) + } + return &dto.ConsoleUserList{Items: items}, nil +} + +// Create inserts one console account (role defaults to viewer). actor is the +// authenticated admin performing the change — it is the audit ledger's +// admin_name, NOT the new account's username. +func (s *ConsoleUserService) Create(actor, username, password, role string) (*dto.ConsoleUserCreated, error) { + username, err := validateConsoleUsername(username) + if err != nil { + return nil, err + } + if err := validateConsolePassword(password); err != nil { + return nil, err + } + if role == "" { + role = repo.RoleViewer + } + if role != repo.RoleAdmin && role != repo.RoleViewer { + return nil, ErrConsoleInvalidRole + } + u, err := repo.CreateAdminUser(username, password, role) + if err != nil { + return nil, err // repo.ErrUserExists -> 409 + } + if err := s.audit(actor, "account_create", "admin_user", + fmt.Sprintf("%d", u.ID), fmt.Sprintf("username=%s role=%s", u.Username, u.Role)); err != nil { + return nil, err + } + return &dto.ConsoleUserCreated{ID: u.ID, Username: u.Username, Role: u.Role}, nil +} + +// ResetPassword sets a new password, forces a change on next login and drops +// every session of that account. actor is the acting admin (audit ledger). +func (s *ConsoleUserService) ResetPassword(actor string, id int64, password string) error { + if err := validateConsolePassword(password); err != nil { + return err + } + target, err := repo.GetUserByID(id) + if err != nil { + return err + } + if target == nil { + return repo.ErrUserNotFound + } + if err := repo.ResetUserPassword(id, password); err != nil { + return err + } + if err := repo.DeleteUserSessions(id); err != nil { + return err + } + return s.audit(actor, "account_password_reset", "admin_user", + fmt.Sprintf("%d", id), fmt.Sprintf("username=%s", target.Username)) +} + +// Delete removes one console account (and its sessions). actorID / actor are +// the authenticated caller: self-deletion and last-admin deletion are refused, +// and the audit ledger records the acting admin (not the deleted account). +func (s *ConsoleUserService) Delete(actorID int64, actor string, id int64) error { + if actorID == id { + return ErrCannotDeleteSelf + } + target, err := repo.GetUserByID(id) + if err != nil { + return err + } + if target == nil { + return repo.ErrUserNotFound + } + if target.Role == repo.RoleAdmin { + admins, err := repo.CountAdminUsersByRole(repo.RoleAdmin) + if err != nil { + return err + } + if admins <= 1 { + return ErrLastAdmin + } + } + if err := repo.DeleteUserSessions(id); err != nil { + return err + } + if err := repo.DeleteAdminUser(id); err != nil { + return err + } + return s.audit(actor, "account_delete", "admin_user", + fmt.Sprintf("%d", id), fmt.Sprintf("username=%s role=%s", target.Username, target.Role)) +} + +func (s *ConsoleUserService) audit(adminName, action, targetType, targetID, detail string) error { + if s.record == nil { + return errors.New("console audit recorder is not configured") + } + if err := s.record(adminName, action, targetType, targetID, detail); err != nil { + return fmt.Errorf("audit %s: %w", action, err) + } + return nil +} + +// validateConsoleUsername enforces the contract's 2..64 character window. +func validateConsoleUsername(username string) (string, error) { + trimmed := strings.TrimSpace(username) + if n := utf8.RuneCountInString(trimmed); n < 2 || n > 64 { + return "", ErrConsoleInvalidUsername + } + return trimmed, nil +} + +// validateConsolePassword enforces 8..128 characters AND bcrypt's hard 72-byte +// input limit (bcrypt would otherwise fail with an opaque error). +func validateConsolePassword(password string) error { + if n := utf8.RuneCountInString(password); n < 8 || n > 128 { + return ErrConsoleInvalidPassword + } + if len(password) > 72 { + return ErrConsolePasswordTooLong + } + return nil +} diff --git a/app-go/app-admin/internal/service/console_user_test.go b/app-go/app-admin/internal/service/console_user_test.go new file mode 100644 index 00000000..3ec757e5 --- /dev/null +++ b/app-go/app-admin/internal/service/console_user_test.go @@ -0,0 +1,144 @@ +package service + +// Console account-management rule tests: self-delete / last-admin guards, +// duplicate handling, password reset side effects (forced change + session +// invalidation) and validation windows. In-memory SQLite, no MySQL. + +import ( + "errors" + "testing" + "time" + + "app-admin/internal/db" + "app-admin/internal/model" + "app-admin/internal/repo" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func setupConsoleTestDB(t *testing.T) { + t.Helper() + prev := db.DB + gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlDB, err := gdb.DB() + if err != nil { + t.Fatalf("sqlite handle: %v", err) + } + sqlDB.SetMaxOpenConns(1) + if err := gdb.AutoMigrate(&model.AdminUser{}, &model.AdminSession{}, &model.AdminAudit{}); err != nil { + t.Fatalf("migrate: %v", err) + } + db.DB = gdb + t.Cleanup(func() { db.DB = prev }) +} + +func seedConsoleUser(t *testing.T, username, password, role string) *model.AdminUser { + t.Helper() + u, err := repo.CreateAdminUser(username, password, role) + if err != nil { + t.Fatalf("seed console user %s: %v", username, err) + } + return u +} + +func TestConsoleDeleteGuards(t *testing.T) { + setupConsoleTestDB(t) + admin := seedConsoleUser(t, "root-admin", "root-pass-123", repo.RoleAdmin) + svc := NewConsoleUserService() + + // Last-admin guard: an actor id that is not the target (defensive path — + // the HTTP route is admin-only, so this protects role transitions/races). + // Only ONE admin exists at this point, so deletion must be refused. + if err := svc.Delete(999, "root-admin", admin.ID); !errors.Is(err, ErrLastAdmin) { + t.Fatalf("delete last admin: got %v, want ErrLastAdmin", err) + } + + // Self-deletion refused even when several admins exist. + other := seedConsoleUser(t, "second-admin", "second-pass-123", repo.RoleAdmin) + if err := svc.Delete(other.ID, "root-admin", other.ID); !errors.Is(err, ErrCannotDeleteSelf) { + t.Fatalf("self delete: got %v, want ErrCannotDeleteSelf", err) + } + + // Deleting one of two admins is allowed. + if err := svc.Delete(admin.ID, "root-admin", other.ID); err != nil { + t.Fatalf("delete second admin: %v", err) + } + if u, _ := repo.GetUserByID(other.ID); u != nil { + t.Fatal("deleted admin still present") + } + + // Unknown account -> repo.ErrUserNotFound (404 at the boundary). + if err := svc.Delete(admin.ID, "root-admin", 123456); !errors.Is(err, repo.ErrUserNotFound) { + t.Fatalf("delete unknown: got %v, want ErrUserNotFound", err) + } +} + +func TestConsoleCreateAndResetRules(t *testing.T) { + setupConsoleTestDB(t) + svc := NewConsoleUserService() + seedConsoleUser(t, "admin", "admin-pass-123", repo.RoleAdmin) + + // Default role is viewer; must_change_password is forced. + created, err := svc.Create("root-admin", "ops-user", "ops-pass-123", "") + if err != nil { + t.Fatalf("create: %v", err) + } + if created.Role != repo.RoleViewer { + t.Fatalf("default role = %q, want viewer", created.Role) + } + row, _ := repo.GetUserByID(created.ID) + if row == nil || !row.MustChangePassword { + t.Fatal("created account must require a password change") + } + + // Duplicate username -> repo.ErrUserExists. + if _, err := svc.Create("root-admin", "ops-user", "ops-pass-123", ""); !errors.Is(err, repo.ErrUserExists) { + t.Fatalf("duplicate create: got %v, want ErrUserExists", err) + } + + // Validation windows (contract: username 2..64, password 8..128). + cases := []struct { + name, username, password, role string + want error + }{ + {"short username", "x", "ops-pass-123", "", ErrConsoleInvalidUsername}, + {"blank username", " ", "ops-pass-123", "", ErrConsoleInvalidUsername}, + {"short password", "ok-name", "short", "", ErrConsoleInvalidPassword}, + {"long password", "ok-name", "0123456789" + "0123456789" + "0123456789" + "0123456789" + + "0123456789" + "0123456789" + "0123456789" + "0123456789" + "0123456789" + + "0123456789" + "0123456789" + "0123456789" + "0123456789", "", ErrConsoleInvalidPassword}, + {"bad role", "ok-name", "ops-pass-123", "superuser", ErrConsoleInvalidRole}, + } + for _, tc := range cases { + if _, err := svc.Create("root-admin", tc.username, tc.password, tc.role); !errors.Is(err, tc.want) { + t.Fatalf("%s: got %v, want %v", tc.name, err, tc.want) + } + } + + // Reset password: forces a change and drops every session. + if err := repo.CreateSession("tok-1", created.ID, "127.0.0.1", "ua", "", time.Now().Add(time.Hour)); err != nil { + t.Fatalf("seed session: %v", err) + } + if err := svc.ResetPassword("root-admin", created.ID, "brand-new-pass"); err != nil { + t.Fatalf("reset password: %v", err) + } + row, _ = repo.GetUserByID(created.ID) + if row == nil || !row.MustChangePassword { + t.Fatal("reset must force must_change_password") + } + if bundle, _ := repo.GetSession("tok-1"); bundle != nil { + t.Fatal("reset must invalidate existing sessions") + } + // Unknown account -> ErrUserNotFound. + if err := svc.ResetPassword("root-admin", 424242, "brand-new-pass"); !errors.Is(err, repo.ErrUserNotFound) { + t.Fatalf("reset unknown: got %v, want ErrUserNotFound", err) + } + // Weak reset password rejected before touching the row. + if err := svc.ResetPassword("root-admin", created.ID, "short"); !errors.Is(err, ErrConsoleInvalidPassword) { + t.Fatalf("weak reset: got %v, want ErrConsoleInvalidPassword", err) + } +} diff --git a/app-pay-admin/internal/service/audit.go b/app-go/app-admin/internal/service/pay_audit.go similarity index 65% rename from app-pay-admin/internal/service/audit.go rename to app-go/app-admin/internal/service/pay_audit.go index e35e9edb..630285ff 100644 --- a/app-pay-admin/internal/service/audit.go +++ b/app-go/app-admin/internal/service/pay_audit.go @@ -1,10 +1,13 @@ -// Package service: JSONL audit trail for money-touching operations. +// Package service: JSONL audit trail for money-touching payment operations. // -// This service writes to the pay domain directly (no Java involvement), which -// bypasses app-pay's PayAuditLogger. To keep the fund-operation audit trail -// intact every money-touching mutation is ALSO appended here as one JSON line, -// mirroring app-pay's pay-audit.jsonl convention (event/ts/kv, rotated via -// lumberjack). Write failures never abort the business request — the DB-level +// Migrated from app-pay-admin/internal/service/audit.go (the standalone service +// is retired into app-admin). The payment console writes to app_pay directly +// for the membership grant, which bypasses app-pay's Java PayAuditLogger. To +// keep the fund-operation audit trail intact every money-touching mutation is +// ALSO appended here as one JSON line, mirroring app-pay's pay-audit.jsonl +// convention (event/ts/kv, rotated via lumberjack, 365 days retention). +// +// Write failures never abort the business request — the DB-level // pay_membership_event row remains the authoritative audit record — but they // are logged loudly. package service @@ -24,10 +27,14 @@ import ( "gopkg.in/natefinch/lumberjack.v2" ) -// Audit appends one JSON object per line to the audit file. +// defaultPayAuditFile is the container path (compose sets +// APPADMIN__PAY__AUDIT_FILE=/app/logs/pay-audit.jsonl). +const defaultPayAuditFile = "/app/logs/pay-audit.jsonl" + +// PayAudit appends one JSON object per line to the payment audit file. // 防篡改哈希链:每行携带 chain/prev/hash(SHA-256 链),启动从文件尾部 // 续链(跨重启不断链)。多实例同写一个文件时链会分叉,验证器按 chain 分组。 -type Audit struct { +type PayAudit struct { mu sync.Mutex w *lumberjack.Logger prev string @@ -35,20 +42,20 @@ type Audit struct { chainID string } -// NewAudit opens (lazily, via lumberjack) the audit sink at path. Retention is -// deliberately long (365 days) to match app-pay's fund-audit convention. -func NewAudit(path string) *Audit { - if path == "" { - path = "/app/logs/pay-admin-audit.jsonl" +// NewPayAudit opens (lazily, via lumberjack) the audit sink at path. Retention +// is deliberately long (365 days) to match app-pay's fund-audit convention. +func NewPayAudit(path string) *PayAudit { + if strings.TrimSpace(path) == "" { + path = defaultPayAuditFile } // lumberjack requires the parent directory to exist (local dev uses a // relative path like logs/... which may not exist yet). if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { - slog.Warn("[AUDIT] create audit dir failed", "dir", dir, "err", err) + slog.Warn("[PAY_AUDIT] create audit dir failed", "dir", dir, "err", err) } } - return &Audit{chainID: "ch-" + randomSuffix(), w: &lumberjack.Logger{ + return &PayAudit{chainID: "ch-" + randomSuffix(), w: &lumberjack.Logger{ Filename: path, MaxSize: 100, MaxBackups: 10, @@ -59,8 +66,8 @@ func NewAudit(path string) *Audit { // Close flushes and closes the underlying audit file (graceful shutdown / // test cleanup; Windows cannot delete an open file). -func (a *Audit) Close() error { - if a.w == nil { +func (a *PayAudit) Close() error { + if a == nil || a.w == nil { return nil } return a.w.Close() @@ -69,7 +76,10 @@ func (a *Audit) Close() error { // Log appends one audit entry: {"event": ..., "ts": ..., }. // kv must be key,value alternates; non-string keys are skipped. // ts is UTC RFC3339Nano (app-pay's JSONL uses Instant, also UTC). -func (a *Audit) Log(event string, kv ...any) { +func (a *PayAudit) Log(event string, kv ...any) { + if a == nil { + return + } entry := map[string]any{ "event": event, "ts": time.Now().UTC().Format(time.RFC3339Nano), @@ -89,7 +99,7 @@ func (a *Audit) Log(event string, kv ...any) { } body, err := json.Marshal(entry) if err != nil { - slog.Error("[AUDIT] marshal failed", "event", event, "err", err) + slog.Error("[PAY_AUDIT] marshal failed", "event", event, "err", err) return } hash := sha256Hex(a.prev + "|" + string(body)) @@ -99,11 +109,11 @@ func (a *Audit) Log(event string, kv ...any) { a.prev = hash body, err = json.Marshal(entry) if err != nil { - slog.Error("[AUDIT] marshal failed", "event", event, "err", err) + slog.Error("[PAY_AUDIT] marshal failed", "event", event, "err", err) return } if _, err := a.w.Write(append(body, '\n')); err != nil { - slog.Error("[AUDIT] write failed", "event", event, "err", err) + slog.Error("[PAY_AUDIT] write failed", "event", event, "err", err) } } diff --git a/app-go/app-admin/internal/service/pay_audit_test.go b/app-go/app-admin/internal/service/pay_audit_test.go new file mode 100644 index 00000000..295f697b --- /dev/null +++ b/app-go/app-admin/internal/service/pay_audit_test.go @@ -0,0 +1,93 @@ +package service + +// Fund-audit JSONL tests: hash-chain linkage across lines AND across a restart +// (the chain must resume from the last hash, not restart at GENESIS), plus the +// nil-sink guard used when the pay module is disabled. + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPayAuditHashChainAndResume(t *testing.T) { + path := filepath.Join(t.TempDir(), "pay-audit.jsonl") + + audit := NewPayAudit(path) + audit.Log("MEMBERSHIP_EXTENDED", "operator", "admin", "uid", "5001", "days", 30) + audit.Log("REFUND_CONFIRMED", "operator", "admin", "order_no", "PO1") + if err := audit.Close(); err != nil { + t.Fatalf("close audit: %v", err) + } + + lines := readAuditLines(t, path) + if len(lines) != 2 { + t.Fatalf("expected 2 audit lines, got %d: %+v", len(lines), lines) + } + if lines[0]["prev"] != "GENESIS" { + t.Fatalf("first line must start at GENESIS, got %v", lines[0]["prev"]) + } + if lines[0]["chain"] == "" || lines[0]["chain"] != lines[1]["chain"] { + t.Fatalf("chain id missing/mismatched: %v vs %v", lines[0]["chain"], lines[1]["chain"]) + } + if lines[1]["prev"] != lines[0]["hash"] { + t.Fatalf("chain broken: line2.prev=%v line1.hash=%v", lines[1]["prev"], lines[0]["hash"]) + } + if lines[0]["hash"] == "" || lines[0]["hash"] == lines[1]["hash"] { + t.Fatal("each line must carry a distinct SHA-256 hash") + } + if lines[0]["event"] != "MEMBERSHIP_EXTENDED" || lines[0]["uid"] != "5001" || + lines[0]["operator"] != "admin" || lines[0]["ts"] == "" { + t.Fatalf("audit entry fields: %+v", lines[0]) + } + + // Reopen: the chain resumes from the persisted tail hash. + resumed := NewPayAudit(path) + resumed.Log("REFUND_REJECTED", "operator", "admin", "order_no", "PO2") + if err := resumed.Close(); err != nil { + t.Fatalf("close resumed audit: %v", err) + } + lines = readAuditLines(t, path) + if len(lines) != 3 { + t.Fatalf("expected 3 lines after resume, got %d", len(lines)) + } + if lines[2]["prev"] != lines[1]["hash"] { + t.Fatalf("resume must continue the chain: got prev=%v want %v", lines[2]["prev"], lines[1]["hash"]) + } +} + +func TestPayAuditNilSinkIsSafe(t *testing.T) { + var nilAudit *PayAudit + nilAudit.Log("NOOP", "k", "v") // must not panic when the pay module is off + if err := nilAudit.Close(); err != nil { + t.Fatalf("nil audit close: %v", err) + } +} + +func readAuditLines(t *testing.T, path string) []map[string]any { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open audit file: %v", err) + } + defer f.Close() + var out []map[string]any + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var entry map[string]any + if err := json.Unmarshal(line, &entry); err != nil { + t.Fatalf("parse audit line %q: %v", line, err) + } + out = append(out, entry) + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan audit file: %v", err) + } + return out +} diff --git a/app-pay-admin/internal/service/grant.go b/app-go/app-admin/internal/service/pay_grant.go similarity index 52% rename from app-pay-admin/internal/service/grant.go rename to app-go/app-admin/internal/service/pay_grant.go index e8ed57e5..c2cb7479 100644 --- a/app-pay-admin/internal/service/grant.go +++ b/app-go/app-admin/internal/service/pay_grant.go @@ -1,12 +1,12 @@ -// Package service: membership grant (运营补偿开通). +// Pay grant service: the ONLY fund write app-admin performs directly against +// app_pay (运营补偿开通). // // This REPLICATES app-pay MembershipService.extend semantics (the Java grant // path: extend(uid, days, null, ADMIN_GRANT, reason)) against the same -// pay_membership / pay_membership_event rows, because app-pay-admin writes the -// app_pay database directly: +// pay_membership / pay_membership_event rows: // // SELECT ... FOR UPDATE the member row (serialize concurrent deliveries) -// membership == nil -> expireBefore = now, create row (tier=VIP) +// membership == nil -> expireBefore = now, create row (tier=VIP or given) // else -> expireBefore = expire_at // base = expireBefore > now ? expireBefore : now (未到期顺延 / 已过期从 now 起算) // after = base + days @@ -18,6 +18,12 @@ // this service matches that observable behavior). // - the operator identity rides inside event.reason as "[] ..." // (the table has no operator column and the Java schema must not drift). +// +// Three audit trails: +// 1. pay_membership_event.reason prefix — inside the fund transaction; +// 2. pay-audit.jsonl MEMBERSHIP_EXTENDED — durable fund trail (hash chain); +// 3. admin_audit action="pay_grant" — cross-database, best effort: the money +// has already moved, so an audit failure is logged loudly, not returned. package service import ( @@ -25,41 +31,51 @@ import ( "errors" "fmt" "log/slog" + "strconv" "time" - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" + "app-admin/internal/db" + "app-admin/internal/model" + "app-admin/internal/repo" "gorm.io/gorm" "gorm.io/gorm/clause" ) -// GrantCaps mirrors app-pay GrantQuotaService 的日额度(pay-admin 直写 DB, +// GrantCaps mirrors app-pay GrantQuotaService 的日额度(app-admin 直写 DB, // 必须自己执行同样的熔断,否则控制台的补偿开通绕过执行点)。0 = 不限该维度。 type GrantCaps struct { DailyMaxCount int DailyMaxDays int } -// GrantService performs the controlled membership write. -type GrantService struct { - audit *Audit - caps GrantCaps +// PayGrantService performs the controlled membership write. +type PayGrantService struct { + audit *PayAudit + caps GrantCaps + record AuditRecorder +} + +// NewPayGrantService wires the production admin_audit recorder. +func NewPayGrantService(audit *PayAudit, caps GrantCaps) *PayGrantService { + return &PayGrantService{audit: audit, caps: caps, record: repo.RecordAudit} } -func NewGrantService(audit *Audit, caps GrantCaps) *GrantService { - return &GrantService{audit: audit, caps: caps} +// NewPayGrantServiceWithAudit is the test seam: the router tests run against +// SQLite (no production ledger writer), so they inject a recorder. +func NewPayGrantServiceWithAudit(audit *PayAudit, caps GrantCaps, record AuditRecorder) *PayGrantService { + return &PayGrantService{audit: audit, caps: caps, record: record} } // GrantResult reports what the grant did (for the API response + audit). type GrantResult struct { - Membership *model.PayMembership - Before time.Time // expire_before snapshot (== now for a fresh member) - After time.Time // expire_after - Created bool // membership row did not exist before + Tier string // resulting tier (VIP when created without an explicit tier) + Before time.Time // expire_before snapshot (== now for a fresh member) + After time.Time // expire_after + Created bool // membership row did not exist before } -// DailyQuotaError — router maps to 429 with the reason. +// DailyQuotaError — the router maps it to 429 with the reason. type DailyQuotaError struct{ Msg string } func (e *DailyQuotaError) Error() string { return e.Msg } @@ -67,40 +83,43 @@ func (e *DailyQuotaError) Error() string { return e.Msg } // checkDailyQuota counts ADMIN_GRANT events since local midnight (the same // table+filter app-pay GrantQuotaService uses, served by the V4 index // idx_pay_pme_type_created). Exceeding either cap aborts the grant. -func (g *GrantService) checkDailyQuota(ctx context.Context, requestedDays int) error { - if g.caps.DailyMaxCount <= 0 && g.caps.DailyMaxDays <= 0 { +func (s *PayGrantService) checkDailyQuota(ctx context.Context, requestedDays int) error { + if s.caps.DailyMaxCount <= 0 && s.caps.DailyMaxDays <= 0 { return nil } now := time.Now() dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) var count, totalDays int64 - if err := db.DB.WithContext(ctx).Raw( + if err := db.PayDB.WithContext(ctx).Raw( "SELECT COUNT(*), COALESCE(SUM(days), 0) FROM pay_membership_event "+ "WHERE type = 'ADMIN_GRANT' AND created_at >= ?", dayStart, ).Row().Scan(&count, &totalDays); err != nil { - // 统计失败不阻塞资金写(与 app-pay 侧 fail-open 一致),但要留痕 - slog.Warn("[GRANT] daily quota query failed -- fail-open", "err", err) + // 统计失败不阻塞资金写(与 app-pay 侧 fail-open 一致),但要留痕。 + slog.Warn("[PAY_GRANT] daily quota query failed -- fail-open", "err", err) return nil } - if g.caps.DailyMaxCount > 0 && count >= int64(g.caps.DailyMaxCount) { - g.audit.Log("GRANT_DAILY_COUNT_EXCEEDED", "count", count, "max", g.caps.DailyMaxCount, "requested_days", requestedDays) - return &DailyQuotaError{Msg: fmt.Sprintf("日补偿开通次数已达上限(%d/%d)", count, g.caps.DailyMaxCount)} + if s.caps.DailyMaxCount > 0 && count >= int64(s.caps.DailyMaxCount) { + s.logAudit("GRANT_DAILY_COUNT_EXCEEDED", "count", count, + "max", s.caps.DailyMaxCount, "requested_days", requestedDays) + return &DailyQuotaError{Msg: fmt.Sprintf("日补偿开通次数已达上限(%d/%d)", count, s.caps.DailyMaxCount)} } - if g.caps.DailyMaxDays > 0 && totalDays+int64(requestedDays) > int64(g.caps.DailyMaxDays) { - g.audit.Log("GRANT_DAILY_DAYS_EXCEEDED", "days", totalDays, "max", g.caps.DailyMaxDays, "requested_days", requestedDays) - return &DailyQuotaError{Msg: fmt.Sprintf("日补偿开通天数已达上限(%d/%d)", totalDays, g.caps.DailyMaxDays)} + if s.caps.DailyMaxDays > 0 && totalDays+int64(requestedDays) > int64(s.caps.DailyMaxDays) { + s.logAudit("GRANT_DAILY_DAYS_EXCEEDED", "days", totalDays, + "max", s.caps.DailyMaxDays, "requested_days", requestedDays) + return &DailyQuotaError{Msg: fmt.Sprintf("日补偿开通天数已达上限(%d/%d)", totalDays, s.caps.DailyMaxDays)} } return nil } -// Grant extends uid's membership by days days. Validation of the request -// happens at the router; this method is purely the transactional write. -func (g *GrantService) Grant(ctx context.Context, operator string, uid int64, days int, reason string, tier string) (*GrantResult, error) { - if err := g.checkDailyQuota(ctx, days); err != nil { +// Grant extends uid's membership by days days. Request validation (uid range, +// day cap, reason length, tier enum) happens at the router; this method is +// purely the transactional write. +func (s *PayGrantService) Grant(ctx context.Context, operator string, uid int64, days int, reason, tier string) (*GrantResult, error) { + if err := s.checkDailyQuota(ctx, days); err != nil { return nil, err } var res *GrantResult - err := db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := db.PayDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var m model.PayMembership err := withRowLock(tx).Where("uid = ?", uid).First(&m).Error now := time.Now() @@ -159,23 +178,24 @@ func (g *GrantService) Grant(ctx context.Context, operator string, uid int64, da return err } - res = &GrantResult{Membership: &m, Before: expireBefore, After: after, Created: created} + res = &GrantResult{Tier: m.Tier, Before: expireBefore, After: after, Created: created} return nil }) if err != nil { return nil, err } - g.audit.Log("MEMBERSHIP_EXTENDED", + uidText := strconv.FormatInt(uid, 10) + s.logAudit("MEMBERSHIP_EXTENDED", "operator", operator, - "uid", uid, + "uid", uidText, "days", days, "expire_before", res.Before.Format(time.RFC3339), "expire_after", res.After.Format(time.RFC3339), "created", res.Created, - "source", "app-pay-admin", + "source", "app-admin", ) - slog.Info("[GRANT] membership extended", + slog.Info("[PAY_GRANT] membership extended", "operator", operator, "uid", uid, "days", days, @@ -183,9 +203,28 @@ func (g *GrantService) Grant(ctx context.Context, operator string, uid int64, da "expire_after", res.After.Format(time.RFC3339), "created", res.Created, ) + + // admin_audit lives in the MAIN database: the grant transaction cannot span + // both connections, so this is best effort — the fund write already + // committed and must not be reported as failed because the ledger hiccuped. + detail := fmt.Sprintf("days=%d tier=%s reason=%s", days, res.Tier, truncateRunes(reason, 200)) + if s.record == nil { + slog.Warn("[PAY_GRANT] admin_audit recorder not configured", "uid", uid) + } else if err := s.record(operator, "pay_grant", "membership", uidText, detail); err != nil { + slog.Error("[PAY_GRANT] admin_audit write failed after grant committed", + "operator", operator, "uid", uid, "err", err) + } return res, nil } +// logAudit appends one JSONL line when the sink is wired (tests may pass nil). +func (s *PayGrantService) logAudit(event string, kv ...any) { + if s.audit == nil { + return + } + s.audit.Log(event, kv...) +} + // withRowLock adds SELECT ... FOR UPDATE on MySQL. SQLite (the unit-test DB) // has no FOR UPDATE syntax and runs single-connection anyway, so the clause is // skipped there; production is MySQL-only. diff --git a/app-go/app-admin/internal/service/pay_query.go b/app-go/app-admin/internal/service/pay_query.go new file mode 100644 index 00000000..24b04466 --- /dev/null +++ b/app-go/app-admin/internal/service/pay_query.go @@ -0,0 +1,278 @@ +// Pay query service: assembles the payment-operations DTOs from the read-only +// pay-domain repos (order / callback / membership / entitlement event / SKU). +// +// Contract notes encoded here: +// - every payment uid is serialised as a STRING (bigint precision safety); +// - money stays integer cents; +// - membership "active" is the lazy-expiry flag (expire_at > now), same as +// app-pay's isActive — no background job flips it. +package service + +import ( + "strconv" + "time" + + "app-admin/internal/dto" + "app-admin/internal/model" + "app-admin/internal/repo" +) + +// PayQueryService is stateless; wires pay-domain repo reads into DTOs. +type PayQueryService struct{} + +func NewPayQueryService() *PayQueryService { return &PayQueryService{} } + +// PayOrderQuery is the parsed GET /api/pay/orders filter set. Nil timestamps +// and empty strings mean "no filter". +type PayOrderQuery struct { + UID *int64 + Status string + Channel string + SkuCode string + OrderNo string + TradeNo string + CreatedFrom *time.Time + CreatedTo *time.Time +} + +// Orders returns one order page, newest first (ORDER BY id DESC). +func (s *PayQueryService) Orders(q PayOrderQuery, page, pageSize int) (*dto.PayOrderPage, error) { + if err := validatePage(page, pageSize); err != nil { + return nil, err + } + limit, offset := pageWindow(page, pageSize) + f := repo.OrderFilter{ + Status: q.Status, + Channel: q.Channel, + SkuCode: q.SkuCode, + OrderNo: q.OrderNo, + TradeNo: q.TradeNo, + CreatedFrom: q.CreatedFrom, + CreatedTo: q.CreatedTo, + Limit: limit, + Offset: offset, + } + if q.UID != nil { + f.UID, f.UIDSet = *q.UID, true + } + orders, total, err := repo.ListOrders(f) + if err != nil { + return nil, err + } + items := make([]dto.PayOrderItem, 0, len(orders)) + for i := range orders { + items = append(items, payOrderItem(&orders[i])) + } + return &dto.PayOrderPage{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +// Order resolves one order by order number, falling back to the channel trade +// number when the caller only has that. (nil, nil) = unknown order -> 404. +func (s *PayQueryService) Order(orderNo, tradeNo string) (*dto.PayOrderItem, error) { + order, err := repo.GetOrderByNo(orderNo) + if err != nil { + return nil, err + } + if order == nil && tradeNo != "" { + if order, err = repo.GetOrderByTradeNo(tradeNo); err != nil { + return nil, err + } + } + if order == nil { + return nil, nil + } + item := payOrderItem(order) + return &item, nil +} + +// Callbacks returns the channel callback ledger for one order, newest first. +func (s *PayQueryService) Callbacks(orderNo string, page, pageSize int) (*dto.PayCallbackPage, error) { + if err := validatePage(page, pageSize); err != nil { + return nil, err + } + limit, offset := pageWindow(page, pageSize) + rows, total, err := repo.ListCallbacks(orderNo, limit, offset) + if err != nil { + return nil, err + } + items := make([]dto.PayCallbackItem, 0, len(rows)) + for i := range rows { + items = append(items, dto.PayCallbackItem{ + OrderNo: rows[i].OrderNo, + Channel: rows[i].Channel, + PayloadMasked: rows[i].PayloadMasked, + SignatureResult: rows[i].SignatureResult, + HandleResult: rows[i].HandleResult, + Message: rows[i].Message, + CreatedAt: rfc3339Time(rows[i].CreatedAt), + }) + } + return &dto.PayCallbackPage{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +// Membership resolves one uid's membership; (nil, nil) = no row (the contract +// says the API answers {"membership": null}, never 404). +func (s *PayQueryService) Membership(uid int64) (*dto.PayMembershipItem, error) { + m, err := repo.GetMembershipByUID(uid) + if err != nil || m == nil { + return nil, err + } + item := payMembershipItem(m) + return &item, nil +} + +// Memberships returns one page of memberships (PK order, newest first). +func (s *PayQueryService) Memberships(page, pageSize int) (*dto.PayMembershipPage, error) { + if err := validatePage(page, pageSize); err != nil { + return nil, err + } + limit, offset := pageWindow(page, pageSize) + rows, total, err := repo.ListMemberships(limit, offset) + if err != nil { + return nil, err + } + items := make([]dto.PayMembershipItem, 0, len(rows)) + for i := range rows { + items = append(items, payMembershipItem(&rows[i])) + } + return &dto.PayMembershipPage{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +// MemberEvents returns one member's entitlement audit stream (ACTIVATE / +// RENEW / ADMIN_GRANT), newest first. +func (s *PayQueryService) MemberEvents(uid int64, page, pageSize int) (*dto.PayMembershipEventPage, error) { + if err := validatePage(page, pageSize); err != nil { + return nil, err + } + limit, offset := pageWindow(page, pageSize) + rows, total, err := repo.ListEventsByUID(uid, limit, offset) + if err != nil { + return nil, err + } + return payEventPage(rows, total, page, pageSize), nil +} + +// Events returns the global entitlement event stream (serves the ADMIN_GRANT +// audit review), optionally filtered by uid / type. +func (s *PayQueryService) Events(uid *int64, typ string, page, pageSize int) (*dto.PayMembershipEventPage, error) { + if err := validatePage(page, pageSize); err != nil { + return nil, err + } + limit, offset := pageWindow(page, pageSize) + f := repo.EventFilter{Type: typ, Limit: limit, Offset: offset} + if uid != nil { + f.UID, f.UIDSet = *uid, true + } + rows, total, err := repo.ListEvents(f) + if err != nil { + return nil, err + } + return payEventPage(rows, total, page, pageSize), nil +} + +// Products returns the SKU catalog ordered for display (sort ASC, id ASC). +func (s *PayQueryService) Products() (*dto.PayProductList, error) { + rows, err := repo.ListProducts() + if err != nil { + return nil, err + } + items := make([]dto.PayProductItem, 0, len(rows)) + for _, p := range rows { + items = append(items, dto.PayProductItem{ + Code: p.Code, + Title: p.Title, + DurationDays: p.DurationDays, + PriceCents: p.PriceCents, + Status: p.Status, + Sort: p.Sort, + CreatedAt: rfc3339Time(p.CreatedAt), + UpdatedAt: rfc3339Time(p.UpdatedAt), + }) + } + return &dto.PayProductList{Items: items}, nil +} + +// Refunds returns one page of maker-checker refund approvals (status "" = +// all), newest id first. Reads pay_refund_approval directly; approve/reject go +// through app-pay (see the router / payops client). +func (s *PayQueryService) Refunds(status string, page, pageSize int) (*dto.PayRefundApprovalPage, error) { + if err := validatePage(page, pageSize); err != nil { + return nil, err + } + limit, offset := pageWindow(page, pageSize) + rows, total, err := repo.ListRefundApprovals(repo.RefundApprovalFilter{ + Status: status, Limit: limit, Offset: offset, + }) + if err != nil { + return nil, err + } + items := make([]dto.PayRefundApprovalItem, 0, len(rows)) + for i := range rows { + items = append(items, dto.PayRefundApprovalItem{ + OrderNo: rows[i].OrderNo, + Maker: rows[i].Maker, + MakerReason: rows[i].MakerReason, + Status: rows[i].Status, + CreatedAt: rfc3339Time(rows[i].CreatedAt), + DecidedAt: rfc3339Ptr(rows[i].DecidedAt), + Checker: rows[i].Checker, + CheckReason: rows[i].CheckReason, + }) + } + return &dto.PayRefundApprovalPage{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +// ── row → DTO mappers ──────────────────────────────────────────────────── + +func payOrderItem(o *model.PayOrder) dto.PayOrderItem { + return dto.PayOrderItem{ + OrderNo: o.OrderNo, + UID: strconv.FormatInt(o.UID, 10), + SkuCode: o.SkuCode, + ProductTitle: o.ProductTitle, + DurationDays: o.DurationDays, + AmountCents: o.AmountCents, + Channel: o.Channel, + Status: o.Status, + Version: o.Version, + ChannelTradeNo: o.ChannelTradeNo, + IdempotencyKey: o.IdempotencyKey, + FailReason: o.FailReason, + ExpiresAt: rfc3339Time(o.ExpiresAt), + PaidAt: rfc3339Ptr(o.PaidAt), + ClosedAt: rfc3339Ptr(o.ClosedAt), + CreatedAt: rfc3339Time(o.CreatedAt), + UpdatedAt: rfc3339Time(o.UpdatedAt), + } +} + +func payMembershipItem(m *model.PayMembership) dto.PayMembershipItem { + return dto.PayMembershipItem{ + UID: strconv.FormatInt(m.UID, 10), + Tier: m.Tier, + Active: m.ExpireAt.After(time.Now()), + ExpireAt: rfc3339Time(m.ExpireAt), + AutoRenew: m.AutoRenew, + LastOrderNo: m.LastOrderNo, + CreatedAt: rfc3339Time(m.CreatedAt), + UpdatedAt: rfc3339Time(m.UpdatedAt), + } +} + +func payEventPage(rows []model.PayMembershipEvent, total int64, page, pageSize int) *dto.PayMembershipEventPage { + items := make([]dto.PayMembershipEventItem, 0, len(rows)) + for i := range rows { + items = append(items, dto.PayMembershipEventItem{ + ID: rows[i].ID, + UID: strconv.FormatInt(rows[i].UID, 10), + Type: rows[i].Type, + OrderNo: rows[i].OrderNo, + Days: rows[i].Days, + ExpireBefore: rfc3339Time(rows[i].ExpireBefore), + ExpireAfter: rfc3339Time(rows[i].ExpireAfter), + Reason: rows[i].Reason, + CreatedAt: rfc3339Time(rows[i].CreatedAt), + }) + } + return &dto.PayMembershipEventPage{Items: items, Total: total, Page: page, PageSize: pageSize} +} diff --git a/app-go/app-admin/main.go b/app-go/app-admin/main.go index 060e88ca..9a67865f 100644 --- a/app-go/app-admin/main.go +++ b/app-go/app-admin/main.go @@ -1,12 +1,14 @@ -// app-admin entrypoint: load config, init DB (shared bilirag MySQL), run the +// app-admin entrypoint: load config, init the shared bilirag MySQL, run the // owned-table Migrate (admin_user / admin_session / admin_audit ONLY — the // guard test in internal/db pins this), seed the default console admin -// account, serve /health + /api/* on 8016, graceful shutdown. +// account, optionally open the SECOND app_pay connection (payment console), +// serve /health + /api/* on 8016, graceful shutdown. // -// Share-moderation admin console (plan/1.0.16): read-only view of users / -// shares metadata + the four moderation columns on cloud_shares. Runs on -// 127.0.0.1 loopback only in compose (no APISIX / no nginx), like -// app-pay-admin. +// Unified admin console: share moderation + payment operations +// (orders / memberships / entitlement events / SKU / compensation grant / +// refund review) + console account management. The former standalone +// app-pay-admin service is retired into this one. Runs on 127.0.0.1 loopback +// only in compose (no APISIX / no nginx). package main import ( @@ -18,6 +20,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -27,6 +30,7 @@ import ( "app-admin/internal/config" "app-admin/internal/db" "app-admin/internal/logger" + "app-admin/internal/payops" "app-admin/internal/repo" "app-admin/internal/router" "app-admin/internal/sandbox" @@ -94,6 +98,12 @@ func main() { slog.Warn("[SANDBOX_ADMIN] base_url/token half-configured — sandbox console proxy DISABLED " + "(set both APPADMIN__SANDBOX_ADMIN__BASE_URL and APPADMIN__SANDBOX_ADMIN__TOKEN)") } + // Optional refund-review workbench: half-configured = warn + hide the whole + // group (404); never block the console over an optional payment feature. + if config.NormalizePayOps(cfg) { + slog.Warn("[PAY_OPS] base_url/key half-configured — refund review DISABLED " + + "(set both APPADMIN__PAY__OPS__BASE_URL and APPADMIN__PAY__OPS__KEY)") + } // MySQL (GORM): the shared bilirag database. cloud_shares / users / rbac_* // rows are read or the four moderation columns are UPDATEd — db.Migrate @@ -124,6 +134,40 @@ func main() { slog.Warn("invalid timezone, falling back to UTC", "tz", cfg.Timezone, "err", err) } + // Payment operations (optional SECOND database: app_pay on app-pay-mysql). + // The pay_* schema is Java-owned (Flyway): open the connection, never + // migrate it. A failure degrades to "payment console disabled" (503 on + // /api/pay/**) instead of taking the whole admin console down. + var ( + payQuery *service.PayQueryService + payGrants *service.PayGrantService + payOps *payops.Client + payAudit *service.PayAudit + ) + if payURL := strings.TrimSpace(cfg.PayRDBMS.URL); payURL != "" { + if err := db.PayInit(payURL, cfg.PayRDBMS.MaxOpenConns, cfg.PayRDBMS.MaxIdleConns, + cfg.PayRDBMS.ConnMaxLifetime, cfg.App.Debug); err != nil { + slog.Error("[PAY] pay database unavailable — payment console DISABLED", "err", err) + } else { + defer db.PayClose() + // Fund-operation JSONL trail (SHA-256 hash chain, 365d retention). + payAudit = service.NewPayAudit(cfg.Pay.AuditFile) + defer payAudit.Close() + payQuery = service.NewPayQueryService() + payGrants = service.NewPayGrantService(payAudit, service.GrantCaps{ + DailyMaxCount: cfg.Pay.Grant.DailyMaxCount, + DailyMaxDays: cfg.Pay.Grant.DailyMaxDays, + }) + payOps = payops.New(cfg.Pay.Ops.BaseURL, cfg.Pay.Ops.Key, cfg.Pay.Ops.TimeoutSeconds) + slog.Info("[PAY] payment console enabled", + "grant_daily_max_count", cfg.Pay.Grant.DailyMaxCount, + "grant_daily_max_days", cfg.Pay.Grant.DailyMaxDays, + "refund_review", payOps.Enabled() && cfg.Pay.Ops.RefundReviewEnabled) + } + } else { + slog.Info("[PAY] payment console disabled — APPADMIN__PAY_RDBMS__URL not set") + } + // Services: auth (session + login throttle), read-only queries, moderation, // and the app-cloud review client (nil unless fully wired — preview off). review := cloud.NewClient(cfg.CloudReview.BaseURL, cfg.CloudReview.Token) @@ -136,13 +180,40 @@ func main() { if !sandboxAdmin.Enabled() { slog.Info("[SANDBOX_ADMIN] console proxy disabled — APPADMIN__SANDBOX_ADMIN__BASE_URL/TOKEN not both set") } - auth := service.NewAuthService(cfg.Session.TTLMinutes) + // Login budgets: short per-IP window + longer per-account window, both + // configured under security.login (zero values fall back to the defaults). + slog.Info("[SECURITY] session binding", + "device", cfg.Session.DeviceBindingMode(), + "ip", cfg.Session.IPBindingMode(), + "ipv4_prefix", cfg.Session.IPv4PrefixOr(), + "ipv6_prefix", cfg.Session.IPv6PrefixOr(), + "trusted_proxies", len(cfg.Security.TrustedProxies)) + auth := service.NewAuthServiceWithLimits(cfg.Session.TTLMinutes, service.LoginLimits{ + IPMax: cfg.Security.Login.MaxFailsPerIP, + IPWindowSeconds: cfg.Security.Login.IPWindowSeconds, + AccountMax: cfg.Security.Login.MaxFailsPerAccount, + AccountWindowSeconds: cfg.Security.Login.AccountWindowSeconds, + }) users := service.NewUserService() shares := service.NewShareService(review) audits := service.NewAuditService() sandboxSvc := service.NewSandboxService(sandboxAdmin) - - handler := router.New(cfg, auth, users, shares, audits, sandboxSvc) + // Console account management (admin_user) is independent of the payment + // database and therefore always wired. + consoleUsers := service.NewConsoleUserService() + + handler := router.New(cfg, router.Deps{ + Auth: auth, + Users: users, + Shares: shares, + Audits: audits, + Sandbox: sandboxSvc, + Pay: payQuery, + Grants: payGrants, + Console: consoleUsers, + Ops: payOps, + PayAudit: payAudit, + }) addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) srv := &http.Server{Addr: addr, Handler: handler} diff --git a/app-pay-admin/.gitignore b/app-pay-admin/.gitignore deleted file mode 100644 index e2ea6d6d..00000000 --- a/app-pay-admin/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# TLS 开发证书(scripts/gen-dev-cert.* 生成) -certs/ -# 本地运行日志(log.output=file/both 时) -logs/ diff --git a/app-pay-admin/Dockerfile b/app-pay-admin/Dockerfile deleted file mode 100644 index 5411057a..00000000 --- a/app-pay-admin/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -# ====== app-pay-admin: pay admin console (Go) ====== -# Multi-stage build: compile static binary, ship on distroless. -# FROM 按 digest 固定:BuildKit 每次都会联网解析 tag(GFW 下 auth.docker.io 超时 -# 直接失败),digest 引用命中本地镜像即可离线构建(同 backend 的既有做法)。 -# 升级基础镜像时:docker pull 后用 docker images --digests 取新 digest 替换。 -FROM golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 AS builder - -WORKDIR /src - -# Use China mirror for Go modules (goproxy.cn) with direct fallback. -# GOSUMDB off: sum.golang.org is often unreachable behind GFW. -ENV GOPROXY=https://goproxy.cn,direct -ENV GOSUMDB=off - -# Cache deps first -COPY go.mod go.sum* ./ -RUN go mod download || true - -# Copy source -COPY . . - -# Static build (CGO disabled for distroless) + empty logs dir template -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app-pay-admin . \ - && mkdir -p /out/empty-logs - -# ---- runtime image ---- -FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab - -LABEL app="mind-base-app-pay-admin" - -# WORKDIR /app: -config config.docker.yaml (compose command) resolves here. -WORKDIR /app -COPY --from=builder /out/app-pay-admin /app-pay-admin -COPY config.docker.yaml /app/config.docker.yaml -# Writable log/audit dir for the nonroot user (volume or plain dir; distroless -# has no shell/mkdir, so the dir is built in the builder stage and chowned). -COPY --from=builder --chown=65532:65532 /out/empty-logs /app/logs - -EXPOSE 8003 -USER nonroot:nonroot - -ENTRYPOINT ["/app-pay-admin"] diff --git a/app-pay-admin/README.md b/app-pay-admin/README.md deleted file mode 100644 index 4ae217ef..00000000 --- a/app-pay-admin/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# app-pay-admin — 支付后台管理(Go + Gin) - -MindBase 支付域(app-pay)的独立后台管理服务:订单 / 会员 / 权益事件 / 回调流水查询 + 会员补偿开通。 - -- **UI = Go html/template 服务端渲染**(`web/templates/`,表单直提 + POST-Redirect-GET + flash 消息,无前端框架/无构建链);风格参照 MinIO 控制台(顶栏 + 水平 Tab)与阿里云控制台(高密度表格/筛选条/状态点)。 -- **`/api/*` JSON 保留**给脚本与 master-token 调用方(与页面共用同一套账户与会话)。 -- 设计文档:[plan/1.0.9-PayAdmin/1.0.9-PayAdmin.md](../plan/1.0.9-PayAdmin/1.0.9-PayAdmin.md);架构与边界见根 `AGENTS.md` §2.9。 - -## 页面一览 - -| 路由 | 说明 | -|------|------| -| `/login`(GET/POST) | 登录(表单提交,失败原页报错) | -| `/orders` → `/orders/{order_no}` | 订单筛选列表 / 详情 + 渠道回调流水 | -| `/memberships` → `/memberships/{uid}/events` | 会员查询(uid 精确卡) / 单人权益事件 | -| `/events` | 全局权益事件流(uid/type 筛选,ADMIN_GRANT 审计) | -| `/products` | SKU 目录(只读) | -| `/grants`(GET/POST) | 补偿开通表单 + 最近 ADMIN_GRANT 记录(admin) | -| `/users`(GET + POST 创建/改密/删除) | 控制台账户管理(admin) | - -## 启动 - -配置分三层(后者覆盖前者,只写差异项即可,不用改来改去): - -``` -default.yaml(嵌入二进制,基础默认) - ← -config 指定的覆盖层文件 - ← PAYADMIN__ 环境变量(最高) -``` - -| 形态 | 覆盖层 | 说明 | -|------|--------|------| -| 本地 dev | `config.dev.yaml` | 直连宿主映射的 app-pay-mysql(127.0.0.1:3307),debug + 文本日志 | -| Docker | `config.docker.yaml` | 已 COPY 进镜像;DB 连接由 compose 注入 `PAYADMIN__RDBMS__URL` | - -### 方式一:本地 dev(go run) - -> ⚠️ 必须**以模块方式**运行:`go run .`(文件模式 `go run main.go` 不在模块上下文里,会报 -> `package app-pay-admin/... is not in std`)。 - -```powershell -cd app-pay-admin -go run . # 自动探测并加载工作目录下的 config.dev.yaml -``` - -三种等价写法(任选):裸 `go run .`(自动探测)、`go run . config.dev.yaml`(位置参数)、 -`go run . -config config.dev.yaml`(显式 flag)。启动日志会打印 `[CONFIG] overlay loaded` -表明实际加载了哪个文件。不需要再手动设任何环境变量;DSN 口令取自 `config.dev.yaml` -(默认 `app-pay`,与根 `.env` 的 `PAY_MYSQL_PASSWORD` 保持一致即可)。 - -### 方式二:Docker(推荐日常使用) - -```bash -# 从项目根目录(profiles 显式 opt-in;depends_on 自动等 app-pay-mysql 健康后启动) -docker compose --profile pay-admin up -d --build app-pay-admin -``` - -浏览器打开 **http://127.0.0.1:8003**(仅回环绑定,不经 APISIX)。 -compose 已带 `command: ["-config", "config.docker.yaml"]` 并注入 DB 连接,无需其他配置。 - -两种形态都会**校验 `rdbms.url`**:缺 DSN 时启动即报错退出(fail loud),不会静默起一个连不上库的服务。 - -## TLS(HTTPS)与跨域(CORS) - -**TLS(可选,默认关闭 = 纯 HTTP 回环)** - -```bash -# 方式一(零配置):开启 TLS 但不指定证书 → 启动时自动生成自签开发证书并保存 -# 到 certs/auto.crt|auto.key(825 天,未到期重启直接复用,无需任何脚本) -# 运行期自动轮换:每日巡检,余量 <30 天自动重生成并热切换(无需重启) -PAYADMIN__SERVER__TLS__ENABLED=true go run . - -# 方式二(无浏览器告警):mini-CA 流程,信任 certs/ca.crt 后不再弹警告 -bash scripts/gen-dev-cert.sh # Windows PowerShell: scripts/gen-dev-cert.ps1 -PAYADMIN__SERVER__TLS__ENABLED=true PAYADMIN__SERVER__TLS__CERT=certs/admin.crt PAYADMIN__SERVER__TLS__KEY=certs/admin.key go run . -``` - -- 启用后登录 cookie 自动带 `Secure`;明文 HTTP 请求被拒(Go TLS 内置行为)。 -- 自动证书由 Go 原生生成(ECDSA P-256,SAN 含 localhost/127.0.0.1/::1/主机名/app-pay-admin,CA:TRUE 可导入信任区);**不依赖 openssl**。 -- **自动轮换**:`tls.Config.GetCertificate` + 每日巡检——自动模式余量 <30 天自动重生成并热切换(新连接立即用新证书,无需重启,文件被删自愈重建);显式模式文件变化(外部续期)自动重载,临期仅告警需运维换发。 -- `cert` 与 `key` 只给一个 → 启动即报错(须成对或同时留空)。 -- 容器内启用:挂载证书卷 + 设置同名 env(见 docker-compose 注释);自动证书默认写容器内 /app/certs(重建容器会重新生成,挂卷可持久化)。生产挂正式 CA 签发证书。 -- Windows curl 校验自签证书需加 `--ssl-no-revoke`(schannel 吊销检查限制,非证书问题)。 - -**跨域(CORS,可选)** - -控制台本身同源访问,无需 CORS。仅当有跨域前端/工具页要带 cookie 调 `/api/*` 时配置白名单: - -``` -PAYADMIN__SECURITY__CORS__ALLOW_ORIGINS=https://a.example.com,https://b.example.com -``` - -精确匹配 Origin 才回显 `Access-Control-Allow-Origin` 并带 `Allow-Credentials: true`;预检 OPTIONS 直接 204。 - -## 首次登录 - -- 种子账户:`admin` / `app-pay-admin`(仅在 `payadmin_user` 表为空时播种) -- **首次登录后请立即在「账户」页改密**;`PAYADMIN__WEBUI__TOKEN` 为可选 master API token(等价 admin,供脚本调用 `/api/*`) - -## 测试 - -```bash -go test ./... # glebarez 内存 SQLite + httptest,无需真实 MySQL -``` diff --git a/app-pay-admin/config.dev.yaml b/app-pay-admin/config.dev.yaml deleted file mode 100644 index c6ad27e0..00000000 --- a/app-pay-admin/config.dev.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# ═══════════════════════════════════════════════════════════════ -# 本地 dev 覆盖层(`go run . -config config.dev.yaml`) -# ------------------------------------------------------------ -# 只写与 default.yaml(嵌入) 不同的项;未写的键沿用嵌入默认值。 -# 优先级:default.yaml(嵌入) < 本文件 < PAYADMIN__ 环境变量 -# (docker 镜像不包含本文件,compose 用 config.docker.yaml) -# ═══════════════════════════════════════════════════════════════ - -app: - debug: true # gin debug 模式 + GORM 逐条 SQL 日志 - -server: - host: 127.0.0.1 - port: 8003 - -rdbms: - # 本地 docker 发布的 app-pay-mysql 宿主端口 - # (compose: "127.0.0.1:${PAY_MYSQL_PORT:-3307}:3306") - # 口令 = 项目根 .env 的 PAY_MYSQL_PASSWORD(默认 app-pay) - url: "mysql+aiomysql://app_pay:app-pay@127.0.0.1:3307/app_pay" - -log: - output: stdout - format: text # 本地开发看纯文本更直观 - -audit: - # 本地审计 JSONL(相对工作目录,目录不存在会自动创建) - file: "logs/pay-admin-audit.jsonl" diff --git a/app-pay-admin/config.docker.yaml b/app-pay-admin/config.docker.yaml deleted file mode 100644 index e70e332b..00000000 --- a/app-pay-admin/config.docker.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# ═══════════════════════════════════════════════════════════════ -# Docker 形态覆盖层(由 Dockerfile COPY 进镜像,compose command 显式指定) -# ------------------------------------------------------------ -# rdbms.url 留空:由 compose 注入 PAYADMIN__RDBMS__URL -# (复用 .env 的 PAY_MYSQL_USER/PASSWORD 连 app-pay-mysql:3306/app_pay)。 -# PAYADMIN__ 环境变量优先级高于本文件,可随时覆盖任意项。 -# ═══════════════════════════════════════════════════════════════ - -server: - host: 0.0.0.0 - # 如需容器内 HTTPS:挂载证书卷并在 .env 设置 - # PAYADMIN__SERVER__TLS__ENABLED=true / PAYADMIN__SERVER__TLS__CERT=/app/certs/admin.crt - # PAYADMIN__SERVER__TLS__KEY=/app/certs/admin.key(默认关闭 = 纯 HTTP 回环)。 - -log: - output: both # 容器内同时落 /app/logs 卷(docker compose logs 也能看) - -audit: - file: "/app/logs/pay-admin-audit.jsonl" diff --git a/app-pay-admin/default.yaml b/app-pay-admin/default.yaml deleted file mode 100644 index bf4c2d02..00000000 --- a/app-pay-admin/default.yaml +++ /dev/null @@ -1,68 +0,0 @@ -# app-pay-admin default config, embedded into the binary via //go:embed. -# Environment overrides use the PAYADMIN__ prefix (double underscore = nesting), -# e.g. PAYADMIN__RDBMS__URL. Precedence: real env > project-root .env > yaml. -app: - name: app-pay-admin - debug: false -server: - host: 0.0.0.0 - port: 8003 - # TLS 可选(默认关闭 = 纯 HTTP)。启用后整站 HTTPS,登录 cookie 自动带 Secure。 - # cert/key 成对提供=用指定文件(外部换发后每日巡检自动重载,临期仅告警); - # 双双留空=启动时自动生成自签开发证书并保存到 cert_dir(auto.crt/auto.key, - # 825 天有效,未到期重启复用;浏览器需信任该证书才无告警)。 - # 运行期自动轮换(Rotator,每日巡检):自动模式余量 <30 天或文件丢失 → 重生成 - # 并热切换(GetCertificate,新连接立即生效,无需重启);显式模式文件变化 → 重载。 - # env 覆盖:PAYADMIN__SERVER__TLS__ENABLED/CERT/KEY/CERT_DIR。 - # 无告警的 mini-CA 流程(可选):scripts/gen-dev-cert.sh|.ps1。 - tls: - enabled: false - cert: "" - key: "" - cert_dir: "certs" -timezone: Asia/Shanghai -# app_pay lives on the dedicated app-pay-mysql instance (host port 3307); -# the URL is injected by docker-compose from PAY_MYSQL_USER/PASSWORD. -rdbms: - url: "" - max_open_conns: 10 - max_idle_conns: 5 - conn_max_lifetime: 1800 -webui: - enabled: true - token: "" # optional master API key (admin identity); console login is username/password - session_ttl_minutes: 720 # login session lifetime (0 = default 12h) -# Money-touching operations (membership grant) are appended here as JSONL, -# mirroring app-pay's pay-audit.jsonl convention (direct-DB writes bypass the -# Java-side PayAuditLogger, so this service keeps its own durable trail). -# app-pay 联动(退款复核工作台):资金写只发生在 app-pay 端点(审计/状态机一致), -# 本控制台经 APISIX key-auth(pay-ops consumer)携带 PAY_OPS_KEY 与登录身份。 -# ops_key 留空 = 复核功能隐藏(其余功能不受影响)。 -app_pay: - base_url: "http://apisix:9080" - ops_key: "" - timeout_seconds: 5 - refund_review_enabled: true -# ADMIN_GRANT 日额度(对齐 app-pay GrantQuotaService):pay-admin 直写 -# pay_membership_event,必须自己执行同样的熔断,否则绕过执行点。0 = 不限。 -grant: - daily_max_count: 50 - daily_max_days: 1000 -audit: - file: "/app/logs/pay-admin-audit.jsonl" -security: - cors: - # 跨域白名单(精确匹配 Origin)。控制台本身同源无需 CORS;只有跨域前端 - # /工具页要带 cookie 调 /api/* 时才配置。env 覆盖(逗号分隔): - # PAYADMIN__SECURITY__CORS__ALLOW_ORIGINS=https://a.example.com,https://b.example.com - allow_origins: [] -log: - level: info # debug|info|warn|error - format: json # text|json - output: stdout # stdout|file|both - file: - path: /app/logs/app-pay-admin.log - max_size: 100 - max_backups: 7 - max_age: 30 - compress: true diff --git a/app-pay-admin/go.mod b/app-pay-admin/go.mod deleted file mode 100644 index 78e535b4..00000000 --- a/app-pay-admin/go.mod +++ /dev/null @@ -1,57 +0,0 @@ -module app-pay-admin - -go 1.25.0 - -require ( - github.com/gin-gonic/gin v1.12.0 - github.com/glebarez/sqlite v1.11.0 - github.com/joho/godotenv v1.5.1 - golang.org/x/crypto v0.48.0 - gopkg.in/natefinch/lumberjack.v2 v2.2.1 - gopkg.in/yaml.v3 v3.0.1 - gorm.io/driver/mysql v1.6.0 - gorm.io/gorm v1.31.2 -) - -require ( - filippo.io/edwards25519 v1.1.0 // indirect - github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect - github.com/cloudwego/base64x v0.1.6 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect - github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/go-sql-driver/mysql v1.8.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect - github.com/google/uuid v1.3.0 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.1 // indirect - go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect - golang.org/x/arch v0.22.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect - modernc.org/libc v1.22.5 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/sqlite v1.23.1 // indirect -) diff --git a/app-pay-admin/go.sum b/app-pay-admin/go.sum deleted file mode 100644 index 1105a787..00000000 --- a/app-pay-admin/go.sum +++ /dev/null @@ -1,138 +0,0 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= -github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= -github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= -github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= -github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= -github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= -github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= -github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= -github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= -go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= -go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= -golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= -gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= -gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/app-pay-admin/internal/appops/actions.go b/app-pay-admin/internal/appops/actions.go deleted file mode 100644 index c921ee29..00000000 --- a/app-pay-admin/internal/appops/actions.go +++ /dev/null @@ -1,13 +0,0 @@ -package appops - -import "context" - -// ConfirmRefundOrReject dispatches approve/reject for the refund workbench. -// confirm=true -> POST refund (confirm=true, operator=checker) -// confirm=false -> POST refund/reject (operator=checker) -func (c *Client) ConfirmRefundOrReject(ctx context.Context, orderNo, operator, reason string, confirm bool) error { - if confirm { - return c.ConfirmRefund(ctx, orderNo, operator, reason) - } - return c.RejectRefund(ctx, orderNo, operator) -} diff --git a/app-pay-admin/internal/certgen/certgen.go b/app-pay-admin/internal/certgen/certgen.go deleted file mode 100644 index b4f6016f..00000000 --- a/app-pay-admin/internal/certgen/certgen.go +++ /dev/null @@ -1,255 +0,0 @@ -// Package certgen resolves the TLS certificate for HTTPS serving. -// -// Two modes (server.tls): -// - explicit cert+key file paths -> load and use them (production: CA-issued); -// - both empty -> auto-generate a self-signed development certificate and -// PERSIST it under the cert dir (auto.crt/auto.key); subsequent starts -// reuse the files while they remain valid (>72h), so the certificate does -// not rotate on every restart. -// -// Self-signed dev certificates trigger browser warnings until the cert is -// imported into the trust store; for a warning-free workflow use -// scripts/gen-dev-cert.* (mini-CA + ca.crt) instead. -package certgen - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "fmt" - "log/slog" - "math/big" - "net" - "os" - "path/filepath" - "sync/atomic" - "time" -) - -// Ensure returns usable cert/key file paths: -// - certPath+keyPath both set -> load them (fail loud on unreadable/broken); -// - exactly one set -> invalid configuration; -// - both empty -> auto-generate under dir (default "certs") and save. -func Ensure(certPath, keyPath, dir string) (string, string, error) { - switch { - case certPath != "" && keyPath != "": - if _, err := tls.LoadX509KeyPair(certPath, keyPath); err != nil { - return "", "", fmt.Errorf("load tls cert/key: %w", err) - } - return certPath, keyPath, nil - case certPath != "" || keyPath != "": - return "", "", fmt.Errorf("tls cert 与 key 须同时提供,或同时留空(留空=自动生成开发证书)") - } - - if dir == "" { - dir = "certs" - } - certPath = filepath.Join(dir, "auto.crt") - keyPath = filepath.Join(dir, "auto.key") - - // 复用仍在有效期(>72h)的既有证书,避免每次重启都换证书。 - if pair, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil && pair.Leaf != nil { - if time.Until(pair.Leaf.NotAfter) > 72*time.Hour { - return certPath, keyPath, nil - } - } - - certPEM, keyPEM, err := generate(825 * 24 * time.Hour) - if err != nil { - return "", "", fmt.Errorf("generate self-signed cert: %w", err) - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", "", fmt.Errorf("create cert dir: %w", err) - } - if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { - return "", "", fmt.Errorf("write key: %w", err) - } - if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { - return "", "", fmt.Errorf("write cert: %w", err) - } - return certPath, keyPath, nil -} - -// generate creates one self-signed certificate (ECDSA P-256, SANs covering -// loopback/hostname/container name). CA:TRUE so the file can be imported as a -// trust anchor for warning-free local browsing. -func generate(validity time.Duration) (certPEM, keyPEM []byte, err error) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, nil, err - } - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return nil, nil, err - } - host, _ := os.Hostname() - tmpl := x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{CommonName: "app-pay-admin-dev", Organization: []string{"MindBase"}}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(validity), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - IsCA: true, - DNSNames: []string{"localhost", "app-pay-admin"}, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, - } - if host != "" { - tmpl.DNSNames = append(tmpl.DNSNames, host) - } - der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) - if err != nil { - return nil, nil, err - } - keyDER, err := x509.MarshalECPrivateKey(key) - if err != nil { - return nil, nil, err - } - certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) - return certPEM, keyPEM, nil -} - -// ── 运行期自动轮换 ────────────────────────────────────────────────── -// -// Rotator 持有当前生效证书,供 tls.Config.GetCertificate 在每次新 TLS 握手时 -// 取用;后台协程周期检查: -// - 自动模式:余量 < rotateWithin(默认 30 天)或证书文件丢失 → 重新生成并 -// 原子落盘(tmp+rename),新连接立即用新证书,全程不重启; -// - 显式文件模式:文件变化(外部续期/换发)→ 重新加载;余量不足只告警 -// (无法自续,需运维换发)。 -type Rotator struct { - certPath string - keyPath string - auto bool - - current atomic.Pointer[tls.Certificate] - - checkEvery time.Duration // 巡检周期(默认 24h) - rotateWithin time.Duration // 自动模式的重生成阈值(默认 30 天) - genValidity time.Duration // 重生成时的有效期(默认 825 天) -} - -// NewRotator completes the startup resolution (Ensure) and loads the initial -// certificate into memory. -func NewRotator(certPath, keyPath, dir string) (*Rotator, error) { - auto := certPath == "" && keyPath == "" // 双双留空 = 自动生成模式 - certPath, keyPath, err := Ensure(certPath, keyPath, dir) - if err != nil { - return nil, err - } - r := &Rotator{ - certPath: certPath, - keyPath: keyPath, - auto: auto, - checkEvery: 24 * time.Hour, - rotateWithin: 30 * 24 * time.Hour, - genValidity: 825 * 24 * time.Hour, - } - r.reloadOnce() - if r.current.Load() == nil { - return nil, fmt.Errorf("load initial tls certificate from %s", certPath) - } - return r, nil -} - -// GetCertificate is wired into tls.Config; called per new TLS handshake. -func (r *Rotator) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { - if c := r.current.Load(); c != nil { - return c, nil - } - return nil, fmt.Errorf("no tls certificate loaded") -} - -// Start launches the periodic rotation check until stop is closed. -func (r *Rotator) Start(stop <-chan struct{}) { - go func() { - t := time.NewTicker(r.checkEvery) - defer t.Stop() - for { - select { - case <-stop: - return - case <-t.C: - r.reloadOnce() - } - } - }() -} - -// reloadOnce re-reads/regenerates the certificate and swaps it atomically. -// Any failure keeps the currently loaded certificate (log, never crash). -func (r *Rotator) reloadOnce() { - if !r.auto { - pair, err := tls.LoadX509KeyPair(r.certPath, r.keyPath) - if err != nil { - slog.Error("[TLS] reload explicit certificate failed, keeping current", "err", err) - return - } - r.current.Store(&pair) - if pair.Leaf != nil && time.Until(pair.Leaf.NotAfter) < r.rotateWithin { - slog.Warn("[TLS] certificate nearing expiry — renew externally", - "cert", r.certPath, "not_after", pair.Leaf.NotAfter.Format(time.RFC3339)) - } - return - } - - pair, err := tls.LoadX509KeyPair(r.certPath, r.keyPath) - if err == nil && pair.Leaf != nil && time.Until(pair.Leaf.NotAfter) >= r.rotateWithin { - r.current.Store(&pair) // 仍然健康,无需轮换 - return - } - reason := "nearing expiry" - if err != nil { - reason = "missing/unreadable" - } - if err := r.regenerate(); err != nil { - slog.Error("[TLS] auto rotate failed, keeping current", "reason", reason, "err", err) - return - } - pair, err = tls.LoadX509KeyPair(r.certPath, r.keyPath) - if err != nil { - slog.Error("[TLS] reload rotated certificate failed", "err", err) - return - } - notAfter := "" - if pair.Leaf != nil { - notAfter = pair.Leaf.NotAfter.Format(time.RFC3339) - } - slog.Info("[TLS] certificate rotated", "reason", reason, "cert", r.certPath, "not_after", notAfter) - r.current.Store(&pair) -} - -// regenerate writes the new key/cert via tmp+rename (best-effort atomicity). -func (r *Rotator) regenerate() error { - certPEM, keyPEM, err := generate(r.genValidity) - if err != nil { - return err - } - certTmp, keyTmp := r.certPath+".tmp", r.keyPath+".tmp" - if err := os.WriteFile(keyTmp, keyPEM, 0o600); err != nil { - return err - } - if err := os.WriteFile(certTmp, certPEM, 0o644); err != nil { - _ = os.Remove(keyTmp) - return err - } - if err := os.Rename(keyTmp, r.keyPath); err != nil { - _ = os.Remove(keyTmp) - _ = os.Remove(certTmp) - return err - } - if err := os.Rename(certTmp, r.certPath); err != nil { - _ = os.Remove(certTmp) - return err - } - return nil -} - -// CertPath returns the resolved certificate file path (for startup logging). -func (r *Rotator) CertPath() string { return r.certPath } diff --git a/app-pay-admin/internal/certgen/certgen_test.go b/app-pay-admin/internal/certgen/certgen_test.go deleted file mode 100644 index c78e6fbb..00000000 --- a/app-pay-admin/internal/certgen/certgen_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package certgen - -// Tests for the runtime rotator: auto-mode regeneration near expiry / on -// missing files, and explicit-mode reload when the files change on disk. - -import ( - "crypto/x509" - "encoding/pem" - "math/big" - "os" - "testing" - "time" -) - -func serialOf(t *testing.T, certPath string) *big.Int { - t.Helper() - b, err := os.ReadFile(certPath) - if err != nil { - t.Fatalf("read cert: %v", err) - } - block, _ := pem.Decode(b) - if block == nil { - t.Fatal("cert not PEM") - } - leaf, err := x509.ParseCertificate(block.Bytes) - if err != nil { - t.Fatalf("parse cert: %v", err) - } - return leaf.SerialNumber -} - -func TestRotatorAutoRegeneratesNearExpiry(t *testing.T) { - dir := t.TempDir() - r, err := NewRotator("", "", dir) - if err != nil { - t.Fatalf("new rotator: %v", err) - } - - // 用 2 小时有效期的证书覆盖,模拟"临近过期"。 - certPEM, keyPEM, err := generate(2 * time.Hour) - if err != nil { - t.Fatalf("generate short cert: %v", err) - } - if err := os.WriteFile(r.certPath, certPEM, 0o644); err != nil { - t.Fatalf("write short cert: %v", err) - } - if err := os.WriteFile(r.keyPath, keyPEM, 0o600); err != nil { - t.Fatalf("write short key: %v", err) - } - old := serialOf(t, r.certPath) - - r.reloadOnce() // 余量 2h < 30d 阈值 → 重生成 - - fresh := serialOf(t, r.certPath) - if old.Cmp(fresh) == 0 { - t.Fatal("cert must be regenerated when nearing expiry") - } - cur := r.current.Load() - if cur == nil || cur.Leaf == nil || time.Until(cur.Leaf.NotAfter) < 800*24*time.Hour { - t.Fatal("current cert must be the fresh long-validity one") - } - - // 文件被删 → 下次巡检自愈重建。 - if err := os.Remove(r.certPath); err != nil { - t.Fatalf("remove cert: %v", err) - } - r.reloadOnce() - if _, err := os.Stat(r.certPath); err != nil { - t.Fatalf("cert must self-heal after deletion: %v", err) - } -} - -func TestRotatorExplicitReloadsOnFileChange(t *testing.T) { - dir := t.TempDir() - c, k, err := Ensure("", "", dir) // 生成一对文件作为"外部管理的证书" - if err != nil { - t.Fatalf("seed pair: %v", err) - } - r, err := NewRotator(c, k, "") - if err != nil { - t.Fatalf("new rotator: %v", err) - } - if r.auto { - t.Fatal("explicit paths must not be auto mode") - } - old := serialOf(t, c) - - // 外部换发:覆盖为新证书(串号必不同),巡检后应重载。 - certPEM, keyPEM, err := generate(825 * 24 * time.Hour) - if err != nil { - t.Fatalf("generate new pair: %v", err) - } - if err := os.WriteFile(c, certPEM, 0o644); err != nil { - t.Fatalf("write cert: %v", err) - } - if err := os.WriteFile(k, keyPEM, 0o600); err != nil { - t.Fatalf("write key: %v", err) - } - r.reloadOnce() - if serialOf(t, c).Cmp(old) == 0 { - t.Fatal("test setup: new pair must differ from old") - } - cur := r.current.Load() - if cur == nil || cur.Leaf == nil || cur.Leaf.SerialNumber.Cmp(old) == 0 { - t.Fatal("explicit cert file change must be reloaded into current") - } - - // GetCertificate 回调应返回当前证书。 - got, err := r.GetCertificate(nil) - if err != nil || got.Leaf.SerialNumber.Cmp(cur.Leaf.SerialNumber) != 0 { - t.Fatal("GetCertificate must serve the current certificate") - } -} diff --git a/app-pay-admin/internal/config/config.go b/app-pay-admin/internal/config/config.go deleted file mode 100644 index 82b9379a..00000000 --- a/app-pay-admin/internal/config/config.go +++ /dev/null @@ -1,238 +0,0 @@ -// Package config loads app-pay-admin configuration from embedded default.yaml + -// PAYADMIN__-prefixed env overrides + project-root .env. -package config - -import ( - "fmt" - "os" - "strconv" - "strings" - - "github.com/joho/godotenv" - "gopkg.in/yaml.v3" -) - -type Config struct { - App AppConfig `yaml:"app"` - Server ServerConfig `yaml:"server"` - Timezone string `yaml:"timezone"` - RDBMS RDBMSConfig `yaml:"rdbms"` - WebUI WebUIConfig `yaml:"webui"` - Audit AuditConfig `yaml:"audit"` - - AppPay AppPayConfig `yaml:"app_pay"` - Grant GrantConfig `yaml:"grant"` - Security SecurityConfig `yaml:"security"` - Log LogConfig `yaml:"log"` -} - -type AppConfig struct { - Name string `yaml:"name"` - Debug bool `yaml:"debug"` -} - -type ServerConfig struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - // TLS 可选(默认关闭 = 纯 HTTP 回环):启用后整站 HTTPS。 - // 证书用 scripts/gen-dev-cert.* 生成的开发证书,生产挂正式 CA 签发证书。 - TLS TLSServerConfig `yaml:"tls"` -} - -// TLSServerConfig:enabled;cert/key 成对提供=用指定文件,双双留空=自动生成 -// 自签开发证书并保存到 cert_dir(auto.crt/auto.key,有效期内重启复用)。 -type TLSServerConfig struct { - Enabled bool `yaml:"enabled"` - Cert string `yaml:"cert"` - Key string `yaml:"key"` - CertDir string `yaml:"cert_dir"` // 自动生成证书的保存目录(默认 certs) -} - -type RDBMSConfig struct { - URL string `yaml:"url"` - MaxOpenConns int `yaml:"max_open_conns"` - MaxIdleConns int `yaml:"max_idle_conns"` - ConnMaxLifetime int `yaml:"conn_max_lifetime"` -} - -// WebUIConfig configures the embedded admin console (served by Gin at /). -// Token empty = no master API key (console login is still username/password); -// production SHOULD set PAYADMIN__WEBUI__TOKEN for script access. -type WebUIConfig struct { - Enabled bool `yaml:"enabled"` - Token string `yaml:"token"` - SessionTTLMinutes int `yaml:"session_ttl_minutes"` // login session lifetime (0 = default 12h) -} - -// AppPayConfig wires the refund-review workbench to app-pay's internal endpoints. -// 资金写只发生在 app-pay 端点(审计/状态机一致性);本控制台经 APISIX key-auth -// (pay-ops consumer) 携带 PAY_OPS_KEY 与登录身份(operator)。ops_key 留空 = 复核功能隐藏。 -type AppPayConfig struct { - BaseURL string `yaml:"base_url"` // default http://apisix:9080 - OpsKey string `yaml:"ops_key"` // PAY_OPS_KEY (pay-ops consumer) - TimeoutSeconds int `yaml:"timeout_seconds"` // default 5 - RefundReviewEnabled bool `yaml:"refund_review_enabled"` // master switch -} - -// GrantConfig mirrors app-pay GrantQuotaService 的日额度熔断:pay-admin 直写 -// pay_membership_event,若不自己执行同样的检查,控制台的补偿开通会绕过执行点。0 = 不限。 -type GrantConfig struct { - DailyMaxCount int `yaml:"daily_max_count"` - DailyMaxDays int `yaml:"daily_max_days"` -} - -// AuditConfig configures the JSONL audit trail for money-touching operations. -type AuditConfig struct { - File string `yaml:"file"` -} - -type SecurityConfig struct { - CORS struct { - AllowOrigins []string `yaml:"allow_origins"` - } `yaml:"cors"` -} - -type LogConfig struct { - Level string `yaml:"level"` // debug|info|warn|error (default info) - Format string `yaml:"format"` // text|json (default json) - Output string `yaml:"output"` // stdout|file|both (default stdout) - File LogFileConfig `yaml:"file"` -} - -type LogFileConfig struct { - Path string `yaml:"path"` - MaxSize int `yaml:"max_size"` - MaxBackups int `yaml:"max_backups"` - MaxAge int `yaml:"max_age"` - Compress bool `yaml:"compress"` -} - -// Load parses the embedded default.yaml, then merges the optional overlay -// config file (-config flag; only present keys override), then applies -// PAYADMIN__ env overrides (highest priority): -// -// default.yaml (embedded) -> -config file -> PAYADMIN__* env -// -// overlayPath empty = no overlay (docker default: compose injects env only). -func Load(yamlBytes []byte, overlayPath string) (*Config, error) { - // app-pay-admin/ is one level below project root; .env lives at project root. - _ = godotenv.Load("../.env") - - var cfg Config - if err := yaml.Unmarshal(yamlBytes, &cfg); err != nil { - return nil, fmt.Errorf("parse default.yaml: %w", err) - } - if overlayPath != "" { - b, err := os.ReadFile(overlayPath) - if err != nil { - return nil, fmt.Errorf("read -config %s: %w", overlayPath, err) - } - if err := yaml.Unmarshal(b, &cfg); err != nil { - return nil, fmt.Errorf("parse %s: %w", overlayPath, err) - } - } - applyEnvOverrides(&cfg) - return &cfg, nil -} - -// applyEnvOverrides maps PAYADMIN__SECTION__KEY env vars onto the config -// (mirrors app-task's per-field mapping; no generic reflection magic). -func applyEnvOverrides(cfg *Config) { - get := func(key string) string { return strings.TrimSpace(os.Getenv(key)) } - atoi := func(key string, dst *int) { - if v := get(key); v != "" { - if n, err := strconv.Atoi(v); err == nil { - *dst = n - } - } - } - boolVar := func(key string, dst *bool) { - if v := get(key); v != "" { - *dst = v == "true" || v == "1" - } - } - - if v := get("PAYADMIN__RDBMS__URL"); v != "" { - cfg.RDBMS.URL = v - } - atoi("PAYADMIN__RDBMS__MAX_OPEN_CONNS", &cfg.RDBMS.MaxOpenConns) - atoi("PAYADMIN__RDBMS__MAX_IDLE_CONNS", &cfg.RDBMS.MaxIdleConns) - atoi("PAYADMIN__RDBMS__CONN_MAX_LIFETIME", &cfg.RDBMS.ConnMaxLifetime) - if v := get("PAYADMIN__TIMEZONE"); v != "" { - cfg.Timezone = v - } - atoi("PAYADMIN__SERVER__PORT", &cfg.Server.Port) - boolVar("PAYADMIN__SERVER__TLS__ENABLED", &cfg.Server.TLS.Enabled) - if v := get("PAYADMIN__SERVER__TLS__CERT"); v != "" { - cfg.Server.TLS.Cert = v - } - if v := get("PAYADMIN__SERVER__TLS__KEY"); v != "" { - cfg.Server.TLS.Key = v - } - if v := get("PAYADMIN__SERVER__TLS__CERT_DIR"); v != "" { - cfg.Server.TLS.CertDir = v - } - // CORS 白名单:逗号分隔多个 origin(跨域调用方需要带 cookie 时, - // 白名单 origin 必须精确匹配,且浏览器要求 Allow-Credentials,已默认开启)。 - if v := get("PAYADMIN__SECURITY__CORS__ALLOW_ORIGINS"); v != "" { - origins := cfg.Security.CORS.AllowOrigins[:0] - for _, o := range strings.Split(v, ",") { - if o = strings.TrimSpace(o); o != "" { - origins = append(origins, o) - } - } - cfg.Security.CORS.AllowOrigins = origins - } - boolVar("PAYADMIN__APP__DEBUG", &cfg.App.Debug) - boolVar("PAYADMIN__WEBUI__ENABLED", &cfg.WebUI.Enabled) - if v := get("PAYADMIN__WEBUI__TOKEN"); v != "" { - cfg.WebUI.Token = v - } - atoi("PAYADMIN__WEBUI__SESSION_TTL_MINUTES", &cfg.WebUI.SessionTTLMinutes) - if v := get("PAYADMIN__AUDIT__FILE"); v != "" { - cfg.Audit.File = v - } - if v := get("PAYADMIN__LOG__LEVEL"); v != "" { - cfg.Log.Level = v - } - if v := get("PAYADMIN__LOG__FORMAT"); v != "" { - cfg.Log.Format = v - } - if v := get("PAYADMIN__LOG__OUTPUT"); v != "" { - cfg.Log.Output = v - } - if v := get("PAYADMIN__LOG__FILE__PATH"); v != "" { - cfg.Log.File.Path = v - } - atoi("PAYADMIN__LOG__FILE__MAX_SIZE", &cfg.Log.File.MaxSize) - atoi("PAYADMIN__LOG__FILE__MAX_BACKUPS", &cfg.Log.File.MaxBackups) - atoi("PAYADMIN__LOG__FILE__MAX_AGE", &cfg.Log.File.MaxAge) - if v := get("PAYADMIN__APP_PAY__BASE_URL"); v != "" { - cfg.AppPay.BaseURL = v - } - if v := get("PAYADMIN__APP_PAY__OPS_KEY"); v != "" { - cfg.AppPay.OpsKey = v - } - atoi("PAYADMIN__APP_PAY__TIMEOUT_SECONDS", &cfg.AppPay.TimeoutSeconds) - boolVar("PAYADMIN__APP_PAY__REFUND_REVIEW_ENABLED", &cfg.AppPay.RefundReviewEnabled) - atoi("PAYADMIN__GRANT__DAILY_MAX_COUNT", &cfg.Grant.DailyMaxCount) - atoi("PAYADMIN__GRANT__DAILY_MAX_DAYS", &cfg.Grant.DailyMaxDays) - boolVar("PAYADMIN__LOG__FILE__COMPRESS", &cfg.Log.File.Compress) -} - -// Validate fails loud on config that would silently misbehave: this service -// writes to the pay domain, so starting without a DSN is always a mistake. -func Validate(cfg *Config) error { - if strings.TrimSpace(cfg.RDBMS.URL) == "" { - return fmt.Errorf("rdbms.url is empty: run `go run .` inside app-pay-admin/ " + - "(auto-loads config.dev.yaml), or docker compose injects PAYADMIN__RDBMS__URL") - } - if cfg.Server.TLS.Enabled { - // cert/key 要么成对提供(用指定文件),要么双双留空(自动生成开发证书)。 - if (cfg.Server.TLS.Cert == "") != (cfg.Server.TLS.Key == "") { - return fmt.Errorf("server.tls: cert 与 key 须同时提供,或同时留空" + - "(留空=自动生成自签开发证书并保存到 cert_dir)") - } - } - return nil -} diff --git a/app-pay-admin/internal/config/config_test.go b/app-pay-admin/internal/config/config_test.go deleted file mode 100644 index 866022e5..00000000 --- a/app-pay-admin/internal/config/config_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package config - -// Tests for the layered config loading: embedded base -> -config overlay file -// -> PAYADMIN__ env overrides, plus guards on the committed overlay files -// (config.dev.yaml / config.docker.yaml must stay parseable). - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadOverlayPrecedence(t *testing.T) { - dir := t.TempDir() - overlay := filepath.Join(dir, "overlay.yaml") - // Overlay sets port + url; leaves host untouched. - if err := os.WriteFile(overlay, []byte( - "server:\n port: 9999\nrdbms:\n url: \"mysql+aiomysql://u:p@h:3306/db\"\n", - ), 0o644); err != nil { - t.Fatalf("write overlay: %v", err) - } - - base := []byte("server:\n host: 0.0.0.0\n port: 8003\nrdbms:\n url: \"\"\n") - t.Setenv("PAYADMIN__LOG__LEVEL", "debug") // env beats the overlay file - - cfg, err := Load(base, overlay) - if err != nil { - t.Fatalf("load: %v", err) - } - if cfg.Server.Port != 9999 { - t.Fatalf("overlay port = %d, want 9999", cfg.Server.Port) - } - if cfg.RDBMS.URL != "mysql+aiomysql://u:p@h:3306/db" { - t.Fatalf("overlay url = %q", cfg.RDBMS.URL) - } - if cfg.Server.Host != "0.0.0.0" { - t.Fatalf("base host must survive the overlay, got %q", cfg.Server.Host) - } - if cfg.Log.Level != "debug" { - t.Fatalf("env override must win over everything, got %q", cfg.Log.Level) - } -} - -func TestLoadWithoutOverlay(t *testing.T) { - base := []byte("server:\n port: 8003\n") - cfg, err := Load(base, "") - if err != nil { - t.Fatalf("load without overlay: %v", err) - } - if cfg.Server.Port != 8003 || cfg.RDBMS.URL != "" { - t.Fatalf("base-only config: %+v", cfg) - } -} - -func TestLoadMissingOverlayFailsLoud(t *testing.T) { - if _, err := Load([]byte("app:\n name: x\n"), filepath.Join(t.TempDir(), "nope.yaml")); err == nil { - t.Fatal("an explicitly given -config file that is missing must fail loud") - } -} - -// Guard: the two committed overlay files must stay valid YAML and carry their -// expected shape (dev has a concrete DSN; docker leaves the DSN to env). -func TestCommittedOverlayFiles(t *testing.T) { - base := []byte("server:\n host: 0.0.0.0\n port: 8003\nrdbms:\n url: \"\"\n") - - dev, err := Load(base, "../../config.dev.yaml") - if err != nil { - t.Fatalf("config.dev.yaml: %v", err) - } - if dev.RDBMS.URL == "" { - t.Fatal("config.dev.yaml must pin the local dev DSN (127.0.0.1:3307)") - } - if dev.App.Debug != true { - t.Fatal("config.dev.yaml should enable debug for local development") - } - - docker, err := Load(base, "../../config.docker.yaml") - if err != nil { - t.Fatalf("config.docker.yaml: %v", err) - } - if docker.RDBMS.URL != "" { - t.Fatal("config.docker.yaml must NOT pin a DSN (compose injects PAYADMIN__RDBMS__URL)") - } - if docker.Log.Output != "both" { - t.Fatalf("config.docker.yaml log output = %q, want both", docker.Log.Output) - } -} - -func TestValidateFailsWithoutDSN(t *testing.T) { - if err := Validate(&Config{}); err == nil { - t.Fatal("Validate must fail loud when rdbms.url is empty") - } - if err := Validate(&Config{RDBMS: RDBMSConfig{URL: "mysql+aiomysql://u:p@h/db"}}); err != nil { - t.Fatalf("Validate with DSN: %v", err) - } -} - -func TestValidateTLSCertKeyPairSemantics(t *testing.T) { - base := func() *Config { - return &Config{RDBMS: RDBMSConfig{URL: "mysql+aiomysql://u:p@h/db"}} - } - // 只给 cert 不给 key(或反之)→ fail loud;双双留空=自动生成,合法。 - cfg := base() - cfg.Server.TLS.Enabled = true - cfg.Server.TLS.Cert = "/certs/admin.crt" - if err := Validate(cfg); err == nil { - t.Fatal("TLS enabled with cert but no key must fail loud") - } - cfg.Server.TLS.Key = "/certs/admin.key" - if err := Validate(cfg); err != nil { - t.Fatalf("TLS with both paths: %v", err) - } - // 双双留空 = 自动生成模式,合法。 - cfg.Server.TLS.Cert, cfg.Server.TLS.Key = "", "" - if err := Validate(cfg); err != nil { - t.Fatalf("TLS auto-generate mode: %v", err) - } - // 未启用时路径无所谓。 - cfg.Server.TLS.Enabled = false - cfg.Server.TLS.Cert = "/certs/admin.crt" - if err := Validate(cfg); err != nil { - t.Fatalf("TLS disabled: %v", err) - } -} - -func TestCorsAllowOriginsEnvOverride(t *testing.T) { - base := []byte("server:\n port: 8003\n") - t.Setenv("PAYADMIN__SECURITY__CORS__ALLOW_ORIGINS", - " https://a.example.com , https://b.example.com ,,") - cfg, err := Load(base, "") - if err != nil { - t.Fatalf("load: %v", err) - } - got := cfg.Security.CORS.AllowOrigins - if len(got) != 2 || got[0] != "https://a.example.com" || got[1] != "https://b.example.com" { - t.Fatalf("cors origins = %v, want 2 trimmed entries", got) - } -} - -func TestTLSOverlayParse(t *testing.T) { - dir := t.TempDir() - overlay := filepath.Join(dir, "o.yaml") - if err := os.WriteFile(overlay, []byte( - "server:\n tls:\n enabled: true\n cert: /c/admin.crt\n key: /c/admin.key\n", - ), 0o644); err != nil { - t.Fatalf("write overlay: %v", err) - } - cfg, err := Load([]byte("server:\n host: 0.0.0.0\n"), overlay) - if err != nil { - t.Fatalf("load: %v", err) - } - if !cfg.Server.TLS.Enabled || cfg.Server.TLS.Cert != "/c/admin.crt" || cfg.Server.TLS.Key != "/c/admin.key" { - t.Fatalf("tls overlay: %+v", cfg.Server.TLS) - } -} diff --git a/app-pay-admin/internal/db/db.go b/app-pay-admin/internal/db/db.go deleted file mode 100644 index f229d1d6..00000000 --- a/app-pay-admin/internal/db/db.go +++ /dev/null @@ -1,96 +0,0 @@ -// Package db initializes the GORM MySQL connection. DSN may be SQLAlchemy-style -// (mysql+aiomysql://...) — converted to Go mysql driver DSN. -// -// Schema ownership: the pay_* tables belong to app-pay (Java, schema.sql). -// This service must NEVER AutoMigrate them — Migrate only creates the admin's -// own payadmin_user table, so GORM can never silently drift the pay schema. -package db - -import ( - "fmt" - "strings" - "time" - - "app-pay-admin/internal/logger" - "app-pay-admin/internal/model" - - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -var DB *gorm.DB - -// Init opens MySQL. dsn may be mysql+aiomysql://user:pass@host:port/db. -func Init(dsn string, maxOpen, maxIdle, maxLife int, debug bool) error { - gormDSN := toGormDSN(dsn) - d, err := gorm.Open(mysql.Open(gormDSN), &gorm.Config{Logger: logger.NewGORMLogger(debug)}) - if err != nil { - return fmt.Errorf("open mysql: %w", err) - } - sqlDB, err := d.DB() - if err != nil { - return fmt.Errorf("get *sql.DB: %w", err) - } - sqlDB.SetMaxOpenConns(maxOpen) - sqlDB.SetMaxIdleConns(maxIdle) - sqlDB.SetConnMaxLifetime(time.Duration(maxLife) * time.Second) - DB = d - return nil -} - -func Close() error { - if DB == nil { - return nil - } - sqlDB, err := DB.DB() - if err != nil { - return err - } - return sqlDB.Close() -} - -// Migrate creates ONLY the admin-owned payadmin_user table (accounts for the -// embedded console). The pay_* tables are Java-owned (app-pay schema.sql); -// running AutoMigrate on them could silently add/drop columns in a money -// schema, so they are deliberately excluded here. Their Go models in -// internal/model are plain row mappings used for reads and the one controlled -// membership write. -func Migrate() error { - if err := DB.AutoMigrate( - &model.PayAdminUser{}, - ); err != nil { - return fmt.Errorf("auto migrate: %w", err) - } - return nil -} - -// toGormDSN converts mysql+aiomysql://user:pass@host:port/db -// to user:pass@tcp(host:port)/db?charset=utf8mb4&parseTime=True&loc=Local. -// loc=Local keeps DATETIME semantics identical to app-pay's JVM, whose -// timezone is pinned to Asia/Shanghai (main.go sets time.Local accordingly). -func toGormDSN(sqlalchemyURL string) string { - s := sqlalchemyURL - for _, p := range []string{"mysql+aiomysql://", "mysql+pymysql://", "mysql://"} { - s = strings.TrimPrefix(s, p) - } - // s = user:pass@host:port/db[?params] - atIdx := strings.LastIndex(s, "@") - if atIdx < 0 { - return s + "?charset=utf8mb4&parseTime=True&loc=Local" - } - userPass := s[:atIdx] - hostDB := s[atIdx+1:] - // split "host:port" and "/db[?params]" - slashIdx := strings.Index(hostDB, "/") - if slashIdx < 0 { - // no db specified - return userPass + "@tcp(" + hostDB + ")/?charset=utf8mb4&parseTime=True&loc=Local" - } - hostPort := hostDB[:slashIdx] - rest := hostDB[slashIdx+1:] // db[?params] - // drop any existing query string (we add our own) - if qIdx := strings.Index(rest, "?"); qIdx >= 0 { - rest = rest[:qIdx] - } - return userPass + "@tcp(" + hostPort + ")/" + rest + "?charset=utf8mb4&parseTime=True&loc=Local" -} diff --git a/app-pay-admin/internal/db/db_test.go b/app-pay-admin/internal/db/db_test.go deleted file mode 100644 index 8c9301d8..00000000 --- a/app-pay-admin/internal/db/db_test.go +++ /dev/null @@ -1,53 +0,0 @@ -// Guard test: the admin's Migrate must ONLY create payadmin_user. The pay_* -// tables are owned by app-pay (Java, schema.sql) — if this test ever fails a -// pay_* model leaked into Migrate and the Java schema is at risk of drift. -package db - -import ( - "testing" - - "app-pay-admin/internal/model" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -func TestMigrateOnlyCreatesAdminTables(t *testing.T) { - gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - old := DB - DB = gdb - defer func() { DB = old }() - - if err := Migrate(); err != nil { - t.Fatalf("migrate: %v", err) - } - if !gdb.Migrator().HasTable(&model.PayAdminUser{}) { - t.Fatal("payadmin_user should be created by Migrate") - } - for _, forbidden := range []string{"pay_order", "pay_membership", "pay_membership_event", "pay_callback_log", "pay_product"} { - if gdb.Migrator().HasTable(forbidden) { - t.Fatalf("Migrate must never create %s (owned by app-pay)", forbidden) - } - } -} - -func TestToGormDSN(t *testing.T) { - cases := []struct{ in, want string }{ - { - "mysql+aiomysql://app_pay:pw@app-pay-mysql:3306/app_pay", - "app_pay:pw@tcp(app-pay-mysql:3306)/app_pay?charset=utf8mb4&parseTime=True&loc=Local", - }, - { - "mysql+pymysql://u:p@127.0.0.1:3307/app_pay?extra=1", - "u:p@tcp(127.0.0.1:3307)/app_pay?charset=utf8mb4&parseTime=True&loc=Local", - }, - } - for _, tc := range cases { - if got := toGormDSN(tc.in); got != tc.want { - t.Fatalf("toGormDSN(%q) = %q, want %q", tc.in, got, tc.want) - } - } -} diff --git a/app-pay-admin/internal/logger/logger.go b/app-pay-admin/internal/logger/logger.go deleted file mode 100644 index f5587b3e..00000000 --- a/app-pay-admin/internal/logger/logger.go +++ /dev/null @@ -1,183 +0,0 @@ -// Package logger provides slog-based structured logging infrastructure for -// app-pay-admin (same shape as app-task's logger): -// - Init: configurable level (debug/info/warn/error), format (text/json), -// and output (stdout|file|both) with lumberjack rotation for file mode -// - GinLogger: HTTP access-log middleware writing to slog (replaces gin.Logger) -// - GinRecovery: panic-recovery middleware writing to slog -// - NewGORMLogger: GORM logger backed by slog via gorm's built-in NewSlogLogger -// -// Service-layer code keeps using slog.Info/Warn/Error directly with [MODULE] -// prefixes; this package only wires infrastructure. -package logger - -import ( - "context" - "io" - "log/slog" - "os" - "strings" - "time" - - "github.com/gin-gonic/gin" - "gopkg.in/natefinch/lumberjack.v2" - gormlogger "gorm.io/gorm/logger" -) - -// Options configures the default slog logger. -type Options struct { - Level string // debug|info|warn|error (default info) - Format string // text|json (default json) - Output string // stdout|file|both (default stdout) - File FileOptions // used when Output is file or both -} - -// FileOptions configures lumberjack-based file rotation. -type FileOptions struct { - Path string // log file path (default /app/logs/app-pay-admin.log) - MaxSize int // max MB per file before rotation (default 100) - MaxBackups int // number of old files to keep (default 7) - MaxAge int // max days to retain old files (default 30) - Compress bool // gzip rotated files -} - -// Init configures the default slog logger from opts. Call once at startup, -// before db.Init so NewGORMLogger picks up the configured handler via -// slog.Default(). -func Init(opts Options) { - var lvl slog.Level - switch strings.ToLower(strings.TrimSpace(opts.Level)) { - case "debug": - lvl = slog.LevelDebug - case "warn": - lvl = slog.LevelWarn - case "error": - lvl = slog.LevelError - default: - lvl = slog.LevelInfo - } - w := newWriter(opts) - hOpts := &slog.HandlerOptions{Level: lvl} - var h slog.Handler - if strings.ToLower(strings.TrimSpace(opts.Format)) == "text" { - h = slog.NewTextHandler(w, hOpts) - } else { - h = slog.NewJSONHandler(w, hOpts) - } - slog.SetDefault(slog.New(h)) -} - -// newWriter selects the log destination(s) based on Output. -func newWriter(opts Options) io.Writer { - switch strings.ToLower(strings.TrimSpace(opts.Output)) { - case "file": - return newFileWriter(opts.File) - case "both": - return io.MultiWriter(os.Stdout, newFileWriter(opts.File)) - default: // stdout - return os.Stdout - } -} - -// newFileWriter builds a lumberjack rotating writer, applying sane defaults -// for any zero field. The directory must exist and be writable (mount a volume -// at Path in docker). -func newFileWriter(f FileOptions) io.Writer { - if f.Path == "" { - f.Path = "/app/logs/app-pay-admin.log" - } - if f.MaxSize == 0 { - f.MaxSize = 100 - } - if f.MaxBackups == 0 { - f.MaxBackups = 7 - } - if f.MaxAge == 0 { - f.MaxAge = 30 - } - return &lumberjack.Logger{ - Filename: f.Path, - MaxSize: f.MaxSize, - MaxBackups: f.MaxBackups, - MaxAge: f.MaxAge, - Compress: f.Compress, - } -} - -// GinLogger is a Gin middleware that writes one structured access-log entry per -// request to slog (module=http). Status >=500 -> error, >=400 -> warn, else info. -func GinLogger() gin.HandlerFunc { - return func(c *gin.Context) { - start := time.Now() - path := c.Request.URL.Path - raw := c.Request.URL.RawQuery - - c.Next() - - latency := time.Since(start) - status := c.Writer.Status() - - attrs := []slog.Attr{ - slog.String("module", "http"), - slog.String("method", c.Request.Method), - slog.String("path", path), - slog.Int("status", status), - slog.Int("size", c.Writer.Size()), - slog.Duration("latency", latency), - slog.String("ip", c.ClientIP()), - } - if raw != "" { - attrs = append(attrs, slog.String("query", raw)) - } - if len(c.Errors) > 0 { - attrs = append(attrs, slog.String("err", c.Errors.String())) - } - - ctx := context.Background() - msg := "http request" - switch { - case status >= 500: - slog.LogAttrs(ctx, slog.LevelError, msg, attrs...) - case status >= 400: - slog.LogAttrs(ctx, slog.LevelWarn, msg, attrs...) - default: - slog.LogAttrs(ctx, slog.LevelInfo, msg, attrs...) - } - } -} - -// GinRecovery recovers panics and logs them via slog (level=error), replacing -// gin.Recovery() which writes to stderr. Always aborts with 500. -func GinRecovery() gin.HandlerFunc { - return func(c *gin.Context) { - defer func() { - if rec := recover(); rec != nil { - slog.Error("[HTTP] panic recovered", - "error", rec, - "method", c.Request.Method, - "path", c.Request.URL.Path, - ) - c.AbortWithStatus(500) - } - }() - c.Next() - } -} - -// NewGORMLogger returns a GORM logger backed by slog via GORM's built-in -// NewSlogLogger adapter. SQL logs share the same handler/destination as the app. -// -// - debug=true -> Info level (every query logged) -// - debug=false -> Warn level (only slow queries >= 200ms + errors) -// -// Call after Init so slog.Default() is the configured handler. -func NewGORMLogger(debug bool) gormlogger.Interface { - lvl := gormlogger.Warn - if debug { - lvl = gormlogger.Info - } - return gormlogger.NewSlogLogger(slog.Default(), gormlogger.Config{ - SlowThreshold: 200 * time.Millisecond, - LogLevel: lvl, - Colorful: false, - }) -} diff --git a/app-pay-admin/internal/repo/user.go b/app-pay-admin/internal/repo/user.go deleted file mode 100644 index 38159a1b..00000000 --- a/app-pay-admin/internal/repo/user.go +++ /dev/null @@ -1,149 +0,0 @@ -// Package repo: admin console account store (payadmin_user table — the only -// table this service owns/migrates). -package repo - -import ( - "errors" - "fmt" - "strings" - - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" - - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" -) - -// Seeded default admin account, created on first start (empty table). -// CHANGE THE PASSWORD after first login (console account page). -const ( - DefaultAdminUser = "admin" - DefaultAdminPass = "app-pay-admin" -) - -// Console roles. admin = everything (grant + account management); -// viewer = read-only queries. -const ( - RoleAdmin = "admin" - RoleViewer = "viewer" -) - -var ( - ErrUserNotFound = errors.New("user not found") - ErrUserExists = errors.New("username already exists") - ErrLastAdmin = errors.New("cannot delete the last admin") -) - -func GetUserByUsername(username string) (*model.PayAdminUser, error) { - var u model.PayAdminUser - err := db.DB.Where("username = ?", username).First(&u).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &u, nil -} - -func GetUserByID(id int64) (*model.PayAdminUser, error) { - var u model.PayAdminUser - err := db.DB.First(&u, id).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &u, nil -} - -func ListUsers() ([]model.PayAdminUser, error) { - var us []model.PayAdminUser - if err := db.DB.Order("username").Find(&us).Error; err != nil { - return nil, err - } - return us, nil -} - -func CountUsers() (int64, error) { - var n int64 - err := db.DB.Model(&model.PayAdminUser{}).Count(&n).Error - return n, err -} - -func CountUsersByRole(role string) (int64, error) { - var n int64 - err := db.DB.Model(&model.PayAdminUser{}).Where("role = ?", role).Count(&n).Error - return n, err -} - -// SetUserPassword re-hashes and stores a new password for a user. -func SetUserPassword(id int64, password string) error { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("hash password: %w", err) - } - res := db.DB.Model(&model.PayAdminUser{}).Where("id = ?", id).Update("password_hash", string(hash)) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return ErrUserNotFound - } - return nil -} - -func CreateUser(username, password, role string) (*model.PayAdminUser, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return nil, fmt.Errorf("hash password: %w", err) - } - u := &model.PayAdminUser{ - Username: username, - PasswordHash: string(hash), - Role: role, - } - if err := db.DB.Create(u).Error; err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) || containsUniqueViolation(err) { - return nil, ErrUserExists - } - return nil, err - } - return u, nil -} - -// DeleteUser removes a user. The caller must ensure the target is not the -// authenticated caller and that at least one admin remains. -func DeleteUser(id int64) error { - res := db.DB.Delete(&model.PayAdminUser{}, id) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return ErrUserNotFound - } - return nil -} - -// EnsureDefaultAdmin seeds the default admin account on an empty table so the -// console is always login-gated from first boot. -func EnsureDefaultAdmin() error { - n, err := CountUsers() - if err != nil { - return err - } - if n > 0 { - return nil - } - _, err = CreateUser(DefaultAdminUser, DefaultAdminPass, RoleAdmin) - return err -} - -func containsUniqueViolation(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return strings.Contains(msg, "Duplicate entry") || strings.Contains(msg, "UNIQUE constraint failed") -} diff --git a/app-pay-admin/internal/router/api_test.go b/app-pay-admin/internal/router/api_test.go deleted file mode 100644 index 049127cf..00000000 --- a/app-pay-admin/internal/router/api_test.go +++ /dev/null @@ -1,421 +0,0 @@ -package router - -// Tests for the pay-domain query endpoints and the membership grant write, -// against an in-memory SQLite mirroring the app_pay schema. - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "os" - "strings" - "testing" - "time" - - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" -) - -// seedOrder inserts one pay_order row with sensible defaults. -func seedOrder(t *testing.T, orderNo string, uid int64, status, channel string, cents int64, createdAt time.Time) { - t.Helper() - o := &model.PayOrder{ - OrderNo: orderNo, - UID: uid, - SkuCode: "VIP_MONTHLY", - ProductTitle: "VIP 月卡", - DurationDays: 30, - AmountCents: cents, - Channel: channel, - Status: status, - Version: 1, - ExpiresAt: createdAt.Add(30 * time.Minute), - CreatedAt: createdAt, - UpdatedAt: createdAt, - } - if err := db.DB.Create(o).Error; err != nil { - t.Fatalf("seed order: %v", err) - } -} - -func seedCallback(t *testing.T, orderNo, sig, handle string) { - t.Helper() - payload := `{"masked":"..."}` // stored masked by app-pay already - cb := &model.PayCallbackLog{ - OrderNo: orderNo, - Channel: "ALIPAY", - PayloadMasked: &payload, - SignatureResult: sig, - HandleResult: handle, - CreatedAt: time.Now(), - } - if err := db.DB.Create(cb).Error; err != nil { - t.Fatalf("seed callback: %v", err) - } -} - -// ── orders ─────────────────────────────────────────────────────────── - -func TestOrdersListAndFilters(t *testing.T) { - h := newTestRouter(t, "") - ah := adminHeaders(t, h) - now := time.Now() - seedOrder(t, "PO-A", 1001, "DELIVERED", "ALIPAY", 1800, now.Add(-3*time.Hour)) - seedOrder(t, "PO-B", 1001, "CLOSED", "MOCK", 4500, now.Add(-2*time.Hour)) - seedOrder(t, "PO-C", 1002, "CREATED", "ALIPAY", 15800, now.Add(-1*time.Hour)) - - // Default listing: newest first, total reflects the filter. - w := doJSON(h, "GET", "/api/orders", "", ah) - if w.Code != http.StatusOK { - t.Fatalf("list orders: got %d, body %s", w.Code, w.Body.String()) - } - var resp struct { - Total int `json:"total"` - Items []struct { - OrderNo string `json:"order_no"` - Status string `json:"status"` - UID int64 `json:"uid"` - } `json:"items"` - } - mustParse(t, w.Body.String(), &resp) - if resp.Total != 3 || len(resp.Items) != 3 || resp.Items[0].OrderNo != "PO-C" || resp.Items[2].OrderNo != "PO-A" { - t.Fatalf("orders listing order/total: %s", w.Body.String()) - } - - // uid filter (idx_pay_order_uid_status). - w = doJSON(h, "GET", "/api/orders?uid=1001", "", ah) - mustParse(t, w.Body.String(), &resp) - if resp.Total != 2 { - t.Fatalf("uid filter total: %s", w.Body.String()) - } - - // status filter. - w = doJSON(h, "GET", "/api/orders?status=CLOSED", "", ah) - mustParse(t, w.Body.String(), &resp) - if resp.Total != 1 || resp.Items[0].OrderNo != "PO-B" { - t.Fatalf("status filter: %s", w.Body.String()) - } - - // Unique order_no lookup. - w = doJSON(h, "GET", "/api/orders?order_no=PO-C", "", ah) - mustParse(t, w.Body.String(), &resp) - if resp.Total != 1 || resp.Items[0].UID != 1002 { - t.Fatalf("order_no filter: %s", w.Body.String()) - } - - // Created_from excludes older rows (seeds: -3h/-2h/-1h; -150min keeps the - // two newest). The +08:00 offset must be query-escaped ('+' decodes to a - // space otherwise). - w = doJSON(h, "GET", "/api/orders?created_from="+url.QueryEscape(now.Add(-150*time.Minute).Format(time.RFC3339)), "", ah) - mustParse(t, w.Body.String(), &resp) - if resp.Total != 2 { - t.Fatalf("created_from filter: %s", w.Body.String()) - } - - // Pagination cap: limit beyond the max clamps to 200 (accepted), invalid - // uid is rejected. - if w := doJSON(h, "GET", "/api/orders?limit=500", "", ah); w.Code != http.StatusOK { - t.Fatalf("limit>max: got %d, want 200 (clamped)", w.Code) - } - if w := doJSON(h, "GET", "/api/orders?uid=abc", "", ah); w.Code != http.StatusBadRequest { - t.Fatalf("invalid uid: got %d, want 400", w.Code) - } -} - -func TestOrderDetailAndCallbacks(t *testing.T) { - h := newTestRouter(t, "") - ah := adminHeaders(t, h) - seedOrder(t, "PO-D", 2001, "PAID", "ALIPAY", 1800, time.Now()) - seedCallback(t, "PO-D", "VALID", "ACCEPTED") - seedCallback(t, "PO-D", "INVALID", "IGNORED") - - // Detail by order number. - w := doJSON(h, "GET", "/api/orders/PO-D", "", ah) - if w.Code != http.StatusOK { - t.Fatalf("order detail: got %d", w.Code) - } - var detail struct { - Order struct { - OrderNo string `json:"order_no"` - AmountCents int64 `json:"amount_cents"` - PaidAt *string `json:"paid_at"` - } `json:"order"` - } - mustParse(t, w.Body.String(), &detail) - if detail.Order.OrderNo != "PO-D" || detail.Order.AmountCents != 1800 || detail.Order.PaidAt != nil { - t.Fatalf("order detail body: %s", w.Body.String()) - } - - // Callback ledger, newest first. - w = doJSON(h, "GET", "/api/orders/PO-D/callbacks", "", ah) - var cbs struct { - Total int `json:"total"` - Items []struct { - SignatureResult string `json:"signature_result"` - HandleResult string `json:"handle_result"` - } `json:"items"` - } - mustParse(t, w.Body.String(), &cbs) - if cbs.Total != 2 || len(cbs.Items) != 2 { - t.Fatalf("callbacks: %s", w.Body.String()) - } - - // Unknown order -> 404 (also without trade_no fallback). - if w := doJSON(h, "GET", "/api/orders/PO-NOPE", "", ah); w.Code != http.StatusNotFound { - t.Fatalf("unknown order: got %d, want 404", w.Code) - } -} - -// ── memberships + events ───────────────────────────────────────────── - -func TestMembershipsAndEvents(t *testing.T) { - h := newTestRouter(t, "") - ah := adminHeaders(t, h) - now := time.Now() - lastOrder := "PO-X" - if err := db.DB.Create(&model.PayMembership{ - UID: 3001, - Tier: "VIP", - ExpireAt: now.Add(10 * 24 * time.Hour), - LastOrderNo: &lastOrder, - CreatedAt: now, UpdatedAt: now, - }).Error; err != nil { - t.Fatalf("seed membership: %v", err) - } - if err := db.DB.Create(&model.PayMembershipEvent{ - UID: 3001, Type: "ACTIVATE", Days: 30, - ExpireBefore: now, ExpireAfter: now.Add(10 * 24 * time.Hour), - CreatedAt: now, - }).Error; err != nil { - t.Fatalf("seed event: %v", err) - } - - // Exact lookup reports active=true for a future expire_at. - w := doJSON(h, "GET", "/api/memberships?uid=3001", "", ah) - var one struct { - Membership *struct { - UID int64 `json:"uid"` - Active bool `json:"active"` - Tier string `json:"tier"` - } `json:"membership"` - } - mustParse(t, w.Body.String(), &one) - if one.Membership == nil || !one.Membership.Active || one.Membership.Tier != "VIP" { - t.Fatalf("membership lookup: %s", w.Body.String()) - } - - // Unknown uid -> membership null (never an error). - w = doJSON(h, "GET", "/api/memberships?uid=9999", "", ah) - mustParse(t, w.Body.String(), &one) - if one.Membership != nil { - t.Fatalf("unknown membership should be null: %s", w.Body.String()) - } - - // Per-uid event stream. - w = doJSON(h, "GET", "/api/memberships/3001/events", "", ah) - var evs struct { - Total int `json:"total"` - Items []struct { - Type string `json:"type"` - } `json:"items"` - } - mustParse(t, w.Body.String(), &evs) - if evs.Total != 1 || evs.Items[0].Type != "ACTIVATE" { - t.Fatalf("member events: %s", w.Body.String()) - } - - // Global stream with type filter (feeds the ADMIN_GRANT audit review). - w = doJSON(h, "GET", "/api/events?type=ADMIN_GRANT", "", ah) - mustParse(t, w.Body.String(), &evs) - if evs.Total != 0 { - t.Fatalf("global events ADMIN_GRANT filter should be empty: %s", w.Body.String()) - } -} - -func TestProductsList(t *testing.T) { - h := newTestRouter(t, "") - ah := adminHeaders(t, h) - now := time.Now() - for i, p := range []model.PayProduct{ - {Code: "VIP_YEARLY", Title: "VIP 年卡", DurationDays: 365, PriceCents: 15800, Status: "ACTIVE", Sort: 2, CreatedAt: now, UpdatedAt: now}, - {Code: "VIP_MONTHLY", Title: "VIP 月卡", DurationDays: 30, PriceCents: 1800, Status: "ACTIVE", Sort: 0, CreatedAt: now, UpdatedAt: now}, - } { - if err := db.DB.Create(&p).Error; err != nil { - t.Fatalf("seed product %d: %v", i, err) - } - } - w := doJSON(h, "GET", "/api/products", "", ah) - var resp struct { - Items []struct { - Code string `json:"code"` - PriceCents int64 `json:"price_cents"` - } `json:"items"` - } - mustParse(t, w.Body.String(), &resp) - if len(resp.Items) != 2 || resp.Items[0].Code != "VIP_MONTHLY" { - t.Fatalf("products sort: %s", w.Body.String()) - } -} - -// ── grant (money-touching write, admin only) ───────────────────────── - -func TestGrantCreatesMembership(t *testing.T) { - h, auditPath := newTestRouterFull(t, "") - ah := adminHeaders(t, h) - - w := doJSON(h, "POST", "/api/grants", - `{"uid":5001,"duration_days":30,"reason":"客诉补偿"}`, ah) - if w.Code != http.StatusOK { - t.Fatalf("grant: got %d, body %s", w.Code, w.Body.String()) - } - var resp struct { - UID int64 `json:"uid"` - Days int `json:"days"` - ExpireAt string `json:"expire_after"` - Created bool `json:"created"` - Active bool `json:"active"` - } - mustParse(t, w.Body.String(), &resp) - if resp.UID != 5001 || resp.Days != 30 || !resp.Created || !resp.Active { - t.Fatalf("grant response: %s", w.Body.String()) - } - - // DB effects: new membership row + ADMIN_GRANT event with operator prefix. - m, err := repo.GetMembershipByUID(5001) - if err != nil || m == nil { - t.Fatalf("membership after grant: %v %v", m, err) - } - want := time.Now().AddDate(0, 0, 30) - if diff := m.ExpireAt.Sub(want); diff > time.Minute || diff < -time.Minute { - t.Fatalf("expire_after = %s, want ~%s", m.ExpireAt, want) - } - evs, total, err := repo.ListEventsByUID(5001, 10, 0) - if err != nil || total != 1 { - t.Fatalf("events after grant: total=%d err=%v", total, err) - } - if evs[0].Type != "ADMIN_GRANT" || evs[0].Days != 30 || evs[0].Reason == nil || - *evs[0].Reason != "[admin] 客诉补偿" { - t.Fatalf("grant event: %+v", evs[0]) - } - - // Audit JSONL got the money-touching line. - b, err := os.ReadFile(auditPath) - if err != nil || !strings.Contains(string(b), `"event":"MEMBERSHIP_EXTENDED"`) || - !strings.Contains(string(b), `"operator":"admin"`) { - t.Fatalf("audit jsonl: err=%v body=%s", err, b) - } -} - -func TestGrantExtendsActiveMemberFromExpireAt(t *testing.T) { - h, _ := newTestRouterFull(t, "") - ah := adminHeaders(t, h) - now := time.Now() - expire := now.Add(10 * 24 * time.Hour) - if err := db.DB.Create(&model.PayMembership{ - UID: 5002, Tier: "VIP", ExpireAt: expire, CreatedAt: now, UpdatedAt: now, - }).Error; err != nil { - t.Fatalf("seed membership: %v", err) - } - - w := doJSON(h, "POST", "/api/grants", `{"uid":5002,"duration_days":5,"reason":"延时"}`, ah) - if w.Code != http.StatusOK { - t.Fatalf("grant active member: got %d, body %s", w.Code, w.Body.String()) - } - m, _ := repo.GetMembershipByUID(5002) - want := expire.AddDate(0, 0, 5) // 未到期:从 expire_at 顺延 - if diff := m.ExpireAt.Sub(want); diff > time.Minute || diff < -time.Minute { - t.Fatalf("expire_after = %s, want ~%s", m.ExpireAt, want) - } -} - -func TestGrantExpiredMemberStartsFromNow(t *testing.T) { - h, _ := newTestRouterFull(t, "") - ah := adminHeaders(t, h) - now := time.Now() - if err := db.DB.Create(&model.PayMembership{ - UID: 5003, Tier: "VIP", ExpireAt: now.Add(-48 * time.Hour), CreatedAt: now, UpdatedAt: now, - }).Error; err != nil { - t.Fatalf("seed membership: %v", err) - } - - w := doJSON(h, "POST", "/api/grants", `{"uid":5003,"duration_days":7,"reason":"恢复"}`, ah) - if w.Code != http.StatusOK { - t.Fatalf("grant expired member: got %d", w.Code) - } - m, _ := repo.GetMembershipByUID(5003) - want := time.Now().AddDate(0, 0, 7) // 已过期:从 now 起算 - if diff := m.ExpireAt.Sub(want); diff > time.Minute || diff < -time.Minute { - t.Fatalf("expire_after = %s, want ~%s", m.ExpireAt, want) - } -} - -func TestGrantValidation(t *testing.T) { - h, _ := newTestRouterFull(t, "") - ah := adminHeaders(t, h) - cases := []struct { - name string - body string - }{ - {"uid zero", `{"uid":0,"duration_days":30,"reason":"x"}`}, - {"days zero", `{"uid":1,"duration_days":0,"reason":"x"}`}, - {"days too large", `{"uid":1,"duration_days":4000,"reason":"x"}`}, - {"empty reason", `{"uid":1,"duration_days":30,"reason":""}`}, - {"reason overflow", fmt.Sprintf(`{"uid":1,"duration_days":30,"reason":"%s"}`, strings.Repeat("长", 200))}, - } - for _, tc := range cases { - if w := doJSON(h, "POST", "/api/grants", tc.body, ah); w.Code != http.StatusBadRequest { - t.Fatalf("%s: got %d, want 400 (body %s)", tc.name, w.Code, w.Body.String()) - } - } -} - -// ── users: last-admin + self-delete protection ────────────────────── - -func TestUserManagementGuards(t *testing.T) { - h := newTestRouter(t, "") - ah := adminHeaders(t, h) - - // Create a second admin, then delete it (allowed: one admin remains). - w := doJSON(h, "POST", "/api/users", `{"username":"boss","password":"boss-pass-123","role":"admin"}`, ah) - if w.Code != http.StatusOK { - t.Fatalf("create admin: got %d, body %s", w.Code, w.Body.String()) - } - var created struct { - ID int64 `json:"id"` - } - mustParse(t, w.Body.String(), &created) - - // Duplicate username -> 409. - if w := doJSON(h, "POST", "/api/users", `{"username":"boss","password":"boss-pass-123"}`, ah); w.Code != http.StatusConflict { - t.Fatalf("duplicate username: got %d, want 409", w.Code) - } - - // Cannot delete self (admin id 1). - if w := doJSON(h, "DELETE", "/api/users/1", "", ah); w.Code != http.StatusBadRequest { - t.Fatalf("delete self: got %d, want 400", w.Code) - } - - // Delete the second admin, then deleting the last remaining admin is blocked. - if w := doJSON(h, "DELETE", fmt.Sprintf("/api/users/%d", created.ID), "", ah); w.Code != http.StatusOK { - t.Fatalf("delete second admin: got %d", w.Code) - } - if w := doJSON(h, "DELETE", "/api/users/1", "", ah); w.Code != http.StatusBadRequest { - t.Fatalf("delete last admin: got %d, want 400", w.Code) - } - - // Invalid role rejected. - if w := doJSON(h, "POST", "/api/users", `{"username":"x1","password":"x1-pass-123","role":"superuser"}`, ah); w.Code != http.StatusBadRequest { - t.Fatalf("invalid role: got %d, want 400", w.Code) - } -} - -// ── shared helpers ─────────────────────────────────────────────────── - -func mustParse(t *testing.T, body string, v any) { - t.Helper() - if err := json.Unmarshal([]byte(body), v); err != nil { - t.Fatalf("parse %q: %v", body, err) - } -} diff --git a/app-pay-admin/internal/router/console.go b/app-pay-admin/internal/router/console.go deleted file mode 100644 index 5e22555e..00000000 --- a/app-pay-admin/internal/router/console.go +++ /dev/null @@ -1,215 +0,0 @@ -// Package router: server-rendered admin console pages (html/template). -// -// Rendering model: each page is parsed together with base.tmpl into its own -// template set at startup (the page's {{define "content"}} overrides the -// base's empty block). Data is pre-shaped in Go handlers (formatted money / -// times / status text+classes) so templates stay dumb and auto-escaping does -// the XSS work. Interactions follow the classic POST-redirect-GET pattern -// with ?ok=/?err= flash messages; the JSON /api/* group (webui.go) remains -// available for scripts / master-token callers. -// -// Page layout: MinIO-style top bar + horizontal tab navigation; information -// density, filter bars and status dots follow Aliyun console conventions. -package router - -import ( - "html/template" - "io/fs" - "log/slog" - "net/http" - "strings" - - "app-pay-admin/internal/repo" - "app-pay-admin/web" - - "github.com/gin-gonic/gin" -) - -var pageTemplates map[string]*template.Template - -func init() { - ssrPages := []string{ - "orders", "order_detail", "memberships", "member_events", - "events", "products", "grants", "users", "refunds", - } - pageTemplates = make(map[string]*template.Template, len(ssrPages)+2) - for _, p := range ssrPages { - pageTemplates[p] = template.Must(template.New("base.tmpl"). - ParseFS(web.Templates, "templates/base.tmpl", "templates/"+p+".tmpl")) - } - pageTemplates["base"] = template.Must(template.New("base.tmpl"). - ParseFS(web.Templates, "templates/base.tmpl")) - pageTemplates["login"] = template.Must(template.New("login.tmpl"). - ParseFS(web.Templates, "templates/login.tmpl")) -} - -// renderPage executes a parsed page set. Console pages render from base.tmpl -// (which invokes the page's "content" block); login is standalone. Render -// errors are logged (headers may already be flushed). -func renderPage(w http.ResponseWriter, set string, data any) { - t, ok := pageTemplates[set] - if !ok { - slog.Error("[PAGE] template set missing", "set", set) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - entry := "base.tmpl" - if set == "login" { - entry = "login.tmpl" - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := t.ExecuteTemplate(w, entry, data); err != nil { - slog.Error("[PAGE] render failed", "set", set, "err", err) - } -} - -// registerPages mounts the SSR console: standalone login/logout + the gated -// page tree. GET pages render; POST endpoints follow redirect-after-post. -func (r *Router) registerPages(e *gin.Engine, auth *webuiAuthenticator) { - // Static assets (stylesheet) stay public; *.html is hidden from the static - // route — pages are served only through the gated handlers below. - assetFS, err := fs.Sub(web.Assets, "assets") - if err != nil { - panic("web assets missing: " + err.Error()) - } - e.StaticFS("/assets", http.FS(noHTMLFS{assetFS})) - - e.GET("/login", loginPageHandler(auth)) - e.POST("/login", loginSubmitHandler(auth)) - e.POST("/logout", logoutSubmitHandler(auth)) - - pages := e.Group("/", auth.pageGate()) - pages.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/orders") }) - pages.GET("/orders", r.pageOrders) - pages.GET("/orders/:order_no", r.pageOrderDetail) - pages.GET("/memberships", r.pageMemberships) - pages.GET("/memberships/:uid/events", r.pageMemberEvents) - pages.GET("/events", r.pageEvents) - pages.GET("/products", r.pageProducts) - pages.GET("/grants", r.pageGrants) - pages.POST("/grants", r.handleGrantForm) - pages.GET("/refunds", adminPage(r.pageRefunds)) - pages.POST("/refunds/approve", adminPage(r.handleRefundApproveForm)) - pages.POST("/refunds/reject", adminPage(r.handleRefundRejectForm)) - pages.GET("/users", r.pageUsers) - pages.POST("/users", r.handleUserCreate) - pages.POST("/users/:user_id/password", r.handleUserPassword) - pages.POST("/users/:user_id/delete", r.handleUserDelete) -} - -// adminPage wraps a page/form handler with the admin-role check -// (page flows answer with a redirect flash instead of a JSON 403). -func adminPage(h gin.HandlerFunc) gin.HandlerFunc { - return func(c *gin.Context) { - if s, ok := currentUser(c); !ok || s.Role != repo.RoleAdmin { - redirectFlash(c, "/", "err", "需要 admin 角色") - return - } - h(c) - } -} - -// ── login / logout (form flow) ────────────────────────────────────── - -func loginPageHandler(auth *webuiAuthenticator) gin.HandlerFunc { - return func(c *gin.Context) { - // Already authenticated: nothing to do here. - if _, ok := auth.authenticate(auth.extractToken(c)); ok { - c.Redirect(http.StatusFound, "/") - return - } - renderPage(c.Writer, "login", gin.H{"Error": c.Query("err"), "Username": ""}) - } -} - -func loginSubmitHandler(auth *webuiAuthenticator) gin.HandlerFunc { - return func(c *gin.Context) { - username := strings.TrimSpace(c.PostForm("username")) - password := c.PostForm("password") - ip := c.ClientIP() - if !auth.allow(ip) { - auth.throttleAbort(c) - return - } - sess, ok := auth.verifyUser(username, password) - if !ok { - auth.fail(ip) - renderPage(c.Writer, "login", gin.H{ - "Error": "用户名或密码不正确", - "Username": username, - }) - return - } - auth.reset(ip) - sid, err := auth.newSession(sess) - if err != nil { - slog.Error("[PAGE] issue session failed", "err", err) - http.Error(c.Writer, "internal error", http.StatusInternalServerError) - return - } - http.SetCookie(c.Writer, &http.Cookie{ - Name: webuiSessionCookie, - Value: sid, - Path: "/", - MaxAge: int(auth.ttl.Seconds()), - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Secure: c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https", - }) - slog.Info("[AUTH] console login", "username", sess.Username, "ip", ip) - c.Redirect(http.StatusSeeOther, "/") - } -} - -func logoutSubmitHandler(auth *webuiAuthenticator) gin.HandlerFunc { - return func(c *gin.Context) { - if tok := auth.extractToken(c); tok != "" { - auth.mu.Lock() - delete(auth.sessions, tok) - auth.mu.Unlock() - } - http.SetCookie(c.Writer, &http.Cookie{ - Name: webuiSessionCookie, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - }) - c.Redirect(http.StatusSeeOther, "/login") - } -} - -// pageGate redirects unauthenticated page requests to /login BEFORE any HTML -// is sent, and stashes the identity for handlers (JSON middleware's 302 twin). -func (a *webuiAuthenticator) pageGate() gin.HandlerFunc { - return func(c *gin.Context) { - sess, ok := a.authenticate(a.extractToken(c)) - if !ok { - c.Redirect(http.StatusFound, "/login") - c.Abort() - return - } - c.Set(webuiCtxUser, sess) - c.Next() - } -} - -// noHTMLFS hides *.html from the public /assets static route: pages are only -// served through the gated handlers above. -type noHTMLFS struct{ fs.FS } - -func (n noHTMLFS) Open(name string) (fs.File, error) { - if strings.HasSuffix(name, ".html") { - return nil, fs.ErrNotExist - } - return n.FS.Open(name) -} - -// render404 renders the shell with an error flash (keeps nav usable). -func render404(c *gin.Context, msg string) { - c.Writer.WriteHeader(http.StatusNotFound) - b := newBase(c, "", "未找到", "") - b.Flash = &flashData{Kind: "err", Msg: msg} - renderPage(c.Writer, "base", b) -} diff --git a/app-pay-admin/internal/router/grant.go b/app-pay-admin/internal/router/grant.go deleted file mode 100644 index 022ad674..00000000 --- a/app-pay-admin/internal/router/grant.go +++ /dev/null @@ -1,81 +0,0 @@ -// Package router: POST /api/grants — 运营补偿开通/延期 (admin only). -// -// Request mirrors app-pay's POST /internal/pay/grant contract: -// {"uid": >0, "duration_days": >=1, "reason": required}. The operator identity -// comes from the console session and rides into pay_membership_event.reason as -// "[] ..." (the Java-owned table has no operator column). -package router - -import ( - "net/http" - "strings" - "time" - "unicode/utf8" - - "github.com/gin-gonic/gin" -) - -// maxEventReason is pay_membership_event.reason's column width (VARCHAR(200)). -const maxEventReason = 200 - -// maxGrantDays is a sanity cap (10 years) against fat-finger values; app-pay -// itself only enforces days >= 1. -const maxGrantDays = 3650 - -func (r *Router) apiGrant(c *gin.Context) { - var req struct { - UID int64 `json:"uid" binding:"required,gt=0"` - DurationDays int `json:"duration_days" binding:"required,gt=0,lte=3650"` - Reason string `json:"reason" binding:"required"` - Tier string `json:"tier"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request: " + err.Error()}) - return - } - if req.DurationDays > maxGrantDays { - c.JSON(http.StatusBadRequest, gin.H{"detail": "duration_days too large (max 3650)"}) - return - } - reason := strings.TrimSpace(req.Reason) - if reason == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "reason required"}) - return - } - operator := operatorOf(c) - if operator == "" { - // Defensive: the auth middleware always sets an identity. - c.JSON(http.StatusUnauthorized, gin.H{"detail": "operator identity missing"}) - return - } - // Keep the audit reason within the Java column width after the operator - // prefix is prepended. - finalReason := "[" + operator + "] " + reason - if utf8.RuneCountInString(finalReason) > maxEventReason { - c.JSON(http.StatusBadRequest, gin.H{ - "detail": "reason too long: operator prefix + reason must fit 200 chars", - }) - return - } - - tier := strings.ToUpper(strings.TrimSpace(req.Tier)) - if tier != "" && tier != "VIP" && tier != "SVIP" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "tier must be VIP or SVIP"}) - return - } - res, err := r.grants.Grant(c.Request.Context(), operator, req.UID, req.DurationDays, reason, tier) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "grant failed: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "uid": req.UID, - "tier": res.Membership.Tier, - "days": req.DurationDays, - "expire_before": res.Before.Format(time.RFC3339), - "expire_after": res.After.Format(time.RFC3339), - "created": res.Created, - "active": res.After.After(time.Now()), - }) -} diff --git a/app-pay-admin/internal/router/memberships.go b/app-pay-admin/internal/router/memberships.go deleted file mode 100644 index 4cfd36f0..00000000 --- a/app-pay-admin/internal/router/memberships.go +++ /dev/null @@ -1,124 +0,0 @@ -// Package router: membership + entitlement event endpoints -// (pay_membership / pay_membership_event). -package router - -import ( - "net/http" - "strconv" - "time" - - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -// apiListMemberships GET /api/memberships?uid= -// With uid: exact lookup -> {"membership": {...}|null}. Without: one page. -func (r *Router) apiListMemberships(c *gin.Context) { - if v := c.Query("uid"); v != "" { - uid, err := strconv.ParseInt(v, 10, 64) - if err != nil || uid < 0 { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid uid"}) - return - } - m, err := repo.GetMembershipByUID(uid) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - if m == nil { - c.JSON(http.StatusOK, gin.H{"membership": nil}) - return - } - c.JSON(http.StatusOK, gin.H{"membership": membershipView(m)}) - return - } - - limit, offset := pagination(c, 50, 200) - ms, total, err := repo.ListMemberships(limit, offset) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - items := make([]gin.H, 0, len(ms)) - for i := range ms { - items = append(items, membershipView(&ms[i])) - } - c.JSON(http.StatusOK, gin.H{"total": total, "limit": limit, "offset": offset, "items": items}) -} - -// apiMemberEvents GET /api/memberships/:uid/events — one member's entitlement -// audit stream (ACTIVATE / RENEW / ADMIN_GRANT), newest first. -func (r *Router) apiMemberEvents(c *gin.Context) { - uid, err := strconv.ParseInt(c.Param("uid"), 10, 64) - if err != nil || uid < 0 { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid uid"}) - return - } - limit, offset := pagination(c, 50, 200) - evs, total, err := repo.ListEventsByUID(uid, limit, offset) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - items := make([]gin.H, 0, len(evs)) - for i := range evs { - items = append(items, eventView(&evs[i])) - } - c.JSON(http.StatusOK, gin.H{"total": total, "limit": limit, "offset": offset, "items": items}) -} - -// apiListEvents GET /api/events?uid=&type= — the global entitlement event -// stream (serves the ADMIN_GRANT audit trail review). -func (r *Router) apiListEvents(c *gin.Context) { - limit, offset := pagination(c, 50, 200) - f := repo.EventFilter{Type: c.Query("type"), Limit: limit, Offset: offset} - if v := c.Query("uid"); v != "" { - uid, err := strconv.ParseInt(v, 10, 64) - if err != nil || uid < 0 { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid uid"}) - return - } - f.UID, f.UIDSet = uid, true - } - evs, total, err := repo.ListEvents(f) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - items := make([]gin.H, 0, len(evs)) - for i := range evs { - items = append(items, eventView(&evs[i])) - } - c.JSON(http.StatusOK, gin.H{"total": total, "limit": limit, "offset": offset, "items": items}) -} - -// membershipView adds the lazy-expiry `active` flag (expire_at > now), the -// same semantics as app-pay's isActive. -func membershipView(m *model.PayMembership) gin.H { - return gin.H{ - "uid": m.UID, - "tier": m.Tier, - "active": m.ExpireAt.After(time.Now()), - "expire_at": m.ExpireAt.Format(time.RFC3339), - "auto_renew": m.AutoRenew, - "last_order_no": m.LastOrderNo, - "created_at": m.CreatedAt.Format(time.RFC3339), - "updated_at": m.UpdatedAt.Format(time.RFC3339), - } -} - -func eventView(e *model.PayMembershipEvent) gin.H { - return gin.H{ - "id": e.ID, - "uid": e.UID, - "type": e.Type, - "order_no": e.OrderNo, - "days": e.Days, - "expire_before": e.ExpireBefore.Format(time.RFC3339), - "expire_after": e.ExpireAfter.Format(time.RFC3339), - "reason": e.Reason, - "created_at": e.CreatedAt.Format(time.RFC3339), - } -} diff --git a/app-pay-admin/internal/router/orders.go b/app-pay-admin/internal/router/orders.go deleted file mode 100644 index adc6d8c1..00000000 --- a/app-pay-admin/internal/router/orders.go +++ /dev/null @@ -1,153 +0,0 @@ -// Package router: order query endpoints (pay_order / pay_callback_log). -package router - -import ( - "net/http" - "strconv" - "time" - - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -// parseFlexibleTime accepts RFC3339 ("2026-09-09T20:00:00+08:00"), naive -// datetime-local ("2026-09-09T20:00"), space-separated ("2026-09-09 20:00:05"), -// and plain dates ("2026-09-09"). Values without an offset are interpreted in -// time.Local, which main.go pins to app-pay's Asia/Shanghai wall-clock convention. -func parseFlexibleTime(s string) (time.Time, bool) { - for _, layout := range []string{ - time.RFC3339, "2006-01-02T15:04", "2006-01-02T15:04:05", - "2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02", - } { - if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { - return t, true - } - } - return time.Time{}, false -} - -// apiListOrders GET /api/orders?uid=&status=&channel=&sku=&order_no=&trade_no= -// &created_from=&created_to=&limit=&offset= -func (r *Router) apiListOrders(c *gin.Context) { - limit, offset := pagination(c, 50, 200) - f := repo.OrderFilter{ - Status: c.Query("status"), - Channel: c.Query("channel"), - SkuCode: c.Query("sku"), - OrderNo: c.Query("order_no"), - TradeNo: c.Query("trade_no"), - Limit: limit, - Offset: offset, - } - if v := c.Query("uid"); v != "" { - n, err := strconv.ParseInt(v, 10, 64) - if err != nil || n < 0 { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid uid"}) - return - } - f.UID, f.UIDSet = n, true - } - if v := c.Query("created_from"); v != "" { - t, ok := parseFlexibleTime(v) - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid created_from (expect RFC3339 / date)"}) - return - } - f.CreatedFrom = &t - } - if v := c.Query("created_to"); v != "" { - t, ok := parseFlexibleTime(v) - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid created_to (expect RFC3339 / date)"}) - return - } - f.CreatedTo = &t - } - - orders, total, err := repo.ListOrders(f) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - items := make([]gin.H, 0, len(orders)) - for i := range orders { - items = append(items, orderView(&orders[i])) - } - c.JSON(http.StatusOK, gin.H{"total": total, "limit": limit, "offset": offset, "items": items}) -} - -// apiOrderDetail GET /api/orders/:order_no -func (r *Router) apiOrderDetail(c *gin.Context) { - order, err := repo.GetOrderByNo(c.Param("order_no")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - if order == nil { - // Fall back to the channel trade number so a callback trace can land - // on the order even when the caller only has the trade no. - if trade := c.Query("trade_no"); trade != "" { - if order, err = repo.GetOrderByTradeNo(trade); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - } - } - if order == nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "order not found"}) - return - } - c.JSON(http.StatusOK, gin.H{"order": orderView(order)}) -} - -// apiOrderCallbacks GET /api/orders/:order_no/callbacks — the callback ledger -// for one order (signature/handle verdicts, masked payloads). -func (r *Router) apiOrderCallbacks(c *gin.Context) { - limit, offset := pagination(c, 50, 200) - logs, total, err := repo.ListCallbacks(c.Param("order_no"), limit, offset) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - items := make([]gin.H, 0, len(logs)) - for i := range logs { - items = append(items, callbackView(&logs[i])) - } - c.JSON(http.StatusOK, gin.H{"total": total, "limit": limit, "offset": offset, "items": items}) -} - -func orderView(o *model.PayOrder) gin.H { - return gin.H{ - "order_no": o.OrderNo, - "uid": o.UID, - "sku_code": o.SkuCode, - "product_title": o.ProductTitle, - "duration_days": o.DurationDays, - "amount_cents": o.AmountCents, - "channel": o.Channel, - "status": o.Status, - "version": o.Version, - "channel_trade_no": o.ChannelTradeNo, - "idempotency_key": o.IdempotencyKey, - "fail_reason": o.FailReason, - "expires_at": o.ExpiresAt.Format(time.RFC3339), - "paid_at": timePtr(o.PaidAt), - "closed_at": timePtr(o.ClosedAt), - "created_at": o.CreatedAt.Format(time.RFC3339), - "updated_at": o.UpdatedAt.Format(time.RFC3339), - } -} - -func callbackView(l *model.PayCallbackLog) gin.H { - return gin.H{ - "order_no": l.OrderNo, - "channel": l.Channel, - "payload_masked": l.PayloadMasked, - "signature_result": l.SignatureResult, - "handle_result": l.HandleResult, - "message": l.Message, - "created_at": l.CreatedAt.Format(time.RFC3339), - } -} diff --git a/app-pay-admin/internal/router/pages.go b/app-pay-admin/internal/router/pages.go deleted file mode 100644 index 60f76070..00000000 --- a/app-pay-admin/internal/router/pages.go +++ /dev/null @@ -1,214 +0,0 @@ -// Package router: shared plumbing for SSR pages — base data, flash messages, -// page navigation, and display shaping helpers (money/time/status maps). -package router - -import ( - "fmt" - "net/http" - "net/url" - "strconv" - "time" - - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -// ── base data / flash ─────────────────────────────────────────────── - -type userData struct { - Username string - IsAdmin bool -} - -type flashData struct { - Kind string // ok / err / warn - Msg string -} - -// BaseData is embedded into every page's data struct; templates access its -// fields directly via promotion. -type BaseData struct { - Version string - User userData - Active string // active tab id (orders/memberships/...) - PageTitle string - PageDesc string - Flash *flashData -} - -// newBase builds the shell data from the request: current user, active tab, -// title, and any ?ok=/?err= flash message left by a redirect. -func newBase(c *gin.Context, active, title, desc string) BaseData { - b := BaseData{Version: webuiVersion, Active: active, PageTitle: title, PageDesc: desc} - if s, ok := currentUser(c); ok { - b.User = userData{Username: s.Username, IsAdmin: s.Role == repo.RoleAdmin} - } - if msg := c.Query("ok"); msg != "" { - b.Flash = &flashData{Kind: "ok", Msg: msg} - } else if msg := c.Query("err"); msg != "" { - b.Flash = &flashData{Kind: "err", Msg: msg} - } - return b -} - -// redirectFlash bounces back to path carrying an ?ok=/?err= flash message -// (POST-redirect-GET; messages are url-encoded and auto-escaped on render). -func redirectFlash(c *gin.Context, path, kind, format string, args ...any) { - v := url.Values{} - v.Set(kind, fmt.Sprintf(format, args...)) - sep := "?" - if u, err := url.Parse(path); err == nil && u.RawQuery != "" { - sep = "&" - } - c.Redirect(http.StatusSeeOther, path+sep+v.Encode()) -} - -// ── page navigation ───────────────────────────────────────────────── - -const pageSize = 20 - -type pageNav struct { - Page int - PageSize int - Total int64 - HasPrev bool - HasNext bool - PrevURL string - NextURL string -} - -// queryPage reads the 1-based page number (default 1). -func queryPage(c *gin.Context) int { - if p, err := strconv.Atoi(c.Query("page")); err == nil && p >= 1 { - return p - } - return 1 -} - -// buildPageNav computes prev/next links carrying the current query (filters) -// forward, so pagination never drops the active filters. -func buildPageNav(c *gin.Context, path string, q url.Values, page int, total int64) pageNav { - nav := pageNav{Page: page, PageSize: pageSize, Total: total} - withPage := func(p int) string { - v := url.Values{} - for k, vs := range q { - for _, vv := range vs { - v.Add(k, vv) - } - } - if p > 1 { - v.Set("page", strconv.Itoa(p)) - } - return path + "?" + v.Encode() - } - if page > 1 { - nav.HasPrev = true - nav.PrevURL = withPage(page - 1) - } - if int64(page*pageSize) < total { - nav.HasNext = true - nav.NextURL = withPage(page + 1) - } - return nav -} - -// urlValues converts a filter map into url.Values (skipping empty values). -func urlValues(m map[string]string) url.Values { - v := url.Values{} - for k, val := range m { - if val != "" { - v.Set(k, val) - } - } - return v -} - -// ── display shaping ───────────────────────────────────────────────── - -// fmtMoney renders long cents as ¥x.yy — integer math only, never floats. -func fmtMoney(cents int64) string { - neg := "" - if cents < 0 { - neg = "-" - cents = -cents - } - return fmt.Sprintf("%s¥%d.%02d", neg, cents/100, cents%100) -} - -// fmtTime renders Asia/Shanghai wall-clock (time.Local is pinned in main). -func fmtTime(t time.Time) string { return t.Format("2006-01-02 15:04:05") } - -func fmtTimePtr(t *time.Time) string { - if t == nil { - return "-" - } - return fmtTime(*t) -} - -func fmtStrPtr(p *string) string { - if p == nil || *p == "" { - return "-" - } - return *p -} - -// ── status / type maps (order state machine, callbacks, events) ───── - -var orderStatusText = map[string]string{ - "CREATED": "已创建", "PAID": "已支付", "DELIVERED": "已交付", - "CLOSED": "已关闭", "REFUND_HOLD": "退款冻结", "REFUNDING": "退款中", "REFUNDED": "已退款", -} - -var orderStatusClass = map[string]string{ - "CREATED": "gray", "PAID": "blue", "DELIVERED": "green", - "CLOSED": "gray", "REFUND_HOLD": "red", "REFUNDING": "orange", "REFUNDED": "purple", -} - -var eventTypeText = map[string]string{ - "ACTIVATE": "开通", "RENEW": "续费", "ADMIN_GRANT": "补偿开通", "REFUND_REVOKE": "退款扣回", -} - -var eventTypeClass = map[string]string{ - "ACTIVATE": "green", "RENEW": "blue", "ADMIN_GRANT": "orange", "REFUND_REVOKE": "red", -} - -var signatureClass = map[string]string{"VALID": "green", "SKIPPED": "gray", "INVALID": "red"} - -var handleResultClass = map[string]string{ - "ACCEPTED": "green", "ACCEPTED_DUPLICATE": "green", - "IGNORED": "gray", "ORDER_NOT_FOUND": "gray", "ERROR": "red", -} - -// selectOpt feeds the filter dropdowns (value + label + selected flag). -type selectOpt struct { - V string - T string - Sel bool -} - -func statusOptions(current string) []selectOpt { - opts := make([]selectOpt, 0, len(orderStatusText)) - for _, s := range []string{"CREATED", "PAID", "DELIVERED", "CLOSED", "REFUND_HOLD", "REFUNDING", "REFUNDED"} { - opts = append(opts, selectOpt{V: s, T: orderStatusText[s], Sel: s == current}) - } - return opts -} - -var payChannels = []string{"MOCK", "ALIPAY", "WECHAT_PAY"} - -func channelOptions(current string) []selectOpt { - opts := make([]selectOpt, 0, len(payChannels)) - for _, ch := range payChannels { - opts = append(opts, selectOpt{V: ch, T: ch, Sel: ch == current}) - } - return opts -} - -func eventTypeOptions(current string) []selectOpt { - opts := make([]selectOpt, 0, len(eventTypeText)) - for _, t := range []string{"ACTIVATE", "RENEW", "ADMIN_GRANT", "REFUND_REVOKE"} { - opts = append(opts, selectOpt{V: t, T: eventTypeText[t], Sel: t == current}) - } - return opts -} diff --git a/app-pay-admin/internal/router/pages_events.go b/app-pay-admin/internal/router/pages_events.go deleted file mode 100644 index 1f2019e0..00000000 --- a/app-pay-admin/internal/router/pages_events.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package router: global entitlement event stream page (audit trail review). -package router - -import ( - "log/slog" - "net/http" - "strconv" - - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -func (r *Router) pageEvents(c *gin.Context) { - b := newBase(c, "events", "事件流水", "权益变更审计流:开通 / 续费 / 运营补偿(对账与客诉取证)") - - uidRaw := c.Query("uid") - typ := c.Query("type") - f := repo.EventFilter{Type: typ, Limit: pageSize, Offset: (queryPage(c) - 1) * pageSize} - if uidRaw != "" { - uid, err := strconv.ParseInt(uidRaw, 10, 64) - if err != nil || uid < 0 { - b.Flash = &flashData{Kind: "warn", Msg: "uid 必须为非负整数"} - uidRaw = "" - } else { - f.UID, f.UIDSet = uid, true - } - } - - page := queryPage(c) - evs, total, err := repo.ListEvents(f) - if err != nil { - slog.Error("[PAGE] list events failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - items := make([]eventRow, 0, len(evs)) - for i := range evs { - items = append(items, shapeEvent(&evs[i])) - } - renderPage(c.Writer, "events", struct { - BaseData - UID string - TypeOptions []selectOpt - Items []eventRow - pageNav - }{ - BaseData: b, - UID: uidRaw, - TypeOptions: eventTypeOptions(typ), - Items: items, - pageNav: buildPageNav(c, "/events", urlValues(map[string]string{"uid": uidRaw, "type": typ}), page, total), - }) -} diff --git a/app-pay-admin/internal/router/pages_grants.go b/app-pay-admin/internal/router/pages_grants.go deleted file mode 100644 index 886f3ecc..00000000 --- a/app-pay-admin/internal/router/pages_grants.go +++ /dev/null @@ -1,93 +0,0 @@ -// Package router: membership grant page + form submit (POST-redirect-GET). -package router - -import ( - "log/slog" - "net/http" - "strconv" - "strings" - "unicode/utf8" - - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -type grantRow struct { - CreatedAt string - UID int64 - Days int - ExpireBefore string - ExpireAfter string - Reason string -} - -func (r *Router) pageGrants(c *gin.Context) { - b := newBase(c, "grants", "补偿开通", "会员补偿开通 / 延期(资金写操作:pay_membership_event + 自有 JSONL 双审计留痕)") - - evs, _, err := repo.ListEvents(repo.EventFilter{Type: "ADMIN_GRANT", Limit: 20, Offset: 0}) - if err != nil { - slog.Error("[PAGE] list grants failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - recent := make([]grantRow, 0, len(evs)) - for i := range evs { - recent = append(recent, grantRow{ - CreatedAt: fmtTime(evs[i].CreatedAt), - UID: evs[i].UID, - Days: evs[i].Days, - ExpireBefore: fmtTime(evs[i].ExpireBefore), - ExpireAfter: fmtTime(evs[i].ExpireAfter), - Reason: fmtStrPtr(evs[i].Reason), - }) - } - renderPage(c.Writer, "grants", struct { - BaseData - Recent []grantRow - }{ - BaseData: b, - Recent: recent, - }) -} - -func (r *Router) handleGrantForm(c *gin.Context) { - s, ok := currentUser(c) - if !ok || s.Role != repo.RoleAdmin { - redirectFlash(c, "/grants", "err", "需要 admin 角色") - return - } - uid, err := strconv.ParseInt(strings.TrimSpace(c.PostForm("uid")), 10, 64) - if err != nil || uid < 1 { - redirectFlash(c, "/grants", "err", "uid 不合法(需为正整数)") - return - } - days, err := strconv.Atoi(strings.TrimSpace(c.PostForm("duration_days"))) - if err != nil || days < 1 || days > maxGrantDays { - redirectFlash(c, "/grants", "err", "天数需在 1–%d 之间", maxGrantDays) - return - } - reason := strings.TrimSpace(c.PostForm("reason")) - tier := strings.ToUpper(strings.TrimSpace(c.PostForm("tier"))) - if tier != "" && tier != "VIP" && tier != "SVIP" { - redirectFlash(c, "/grants", "err", "tier 必须为 VIP 或 SVIP") - return - } - if reason == "" { - redirectFlash(c, "/grants", "err", "原因必填(审计留痕)") - return - } - if utf8.RuneCountInString("["+s.Username+"] "+reason) > maxEventReason { - redirectFlash(c, "/grants", "err", "原因过长:操作人前缀 + 原因需 ≤%d 字符", maxEventReason) - return - } - - res, err := r.grants.Grant(c.Request.Context(), s.Username, uid, days, reason, tier) - if err != nil { - slog.Error("[GRANT] form grant failed", "err", err, "operator", s.Username, "uid", uid) - redirectFlash(c, "/grants", "err", "开通失败:%v", err) - return - } - redirectFlash(c, "/grants", "ok", "已为 uid %d 开通 %d 天:%s → %s", - uid, days, fmtTime(res.Before), fmtTime(res.After)) -} diff --git a/app-pay-admin/internal/router/pages_memberships.go b/app-pay-admin/internal/router/pages_memberships.go deleted file mode 100644 index 6478f106..00000000 --- a/app-pay-admin/internal/router/pages_memberships.go +++ /dev/null @@ -1,144 +0,0 @@ -// Package router: membership list + per-member entitlement event pages. -package router - -import ( - "fmt" - "log/slog" - "net/http" - "net/url" - "strconv" - "time" - - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -type memberRow struct { - UID int64 - Tier string - Active bool - AutoRenew bool - ExpireAt string - LastOrderNo string - UpdatedAt string -} - -func shapeMember(m *model.PayMembership) memberRow { - return memberRow{ - UID: m.UID, - Tier: m.Tier, - Active: m.ExpireAt.After(time.Now()), - AutoRenew: m.AutoRenew, - ExpireAt: fmtTime(m.ExpireAt), - LastOrderNo: fmtStrPtr(m.LastOrderNo), - UpdatedAt: fmtTime(m.UpdatedAt), - } -} - -func (r *Router) pageMemberships(c *gin.Context) { - b := newBase(c, "memberships", "会员", "会员实例与到期状态(懒过期:expire_at > now 即生效)") - - // uid 精确查询卡片 - uidRaw := c.Query("uid") - var matched *memberRow - if uidRaw != "" { - uid, err := strconv.ParseInt(uidRaw, 10, 64) - if err != nil || uid < 0 { - b.Flash = &flashData{Kind: "warn", Msg: "uid 必须为非负整数"} - } else { - m, err := repo.GetMembershipByUID(uid) - if err != nil { - slog.Error("[PAGE] get membership failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - if m != nil { - row := shapeMember(m) - matched = &row - } - } - } - - page := queryPage(c) - ms, total, err := repo.ListMemberships(pageSize, (page-1)*pageSize) - if err != nil { - slog.Error("[PAGE] list memberships failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - items := make([]memberRow, 0, len(ms)) - for i := range ms { - items = append(items, shapeMember(&ms[i])) - } - renderPage(c.Writer, "memberships", struct { - BaseData - UID string - Searched bool - Matched *memberRow - Items []memberRow - pageNav - }{ - BaseData: b, - UID: uidRaw, - Searched: uidRaw != "", - Matched: matched, - Items: items, - pageNav: buildPageNav(c, "/memberships", urlValues(map[string]string{"uid": uidRaw}), page, total), - }) -} - -type eventRow struct { - CreatedAt string - UID int64 - TypeText string - TypeClass string - Days int - ExpireBefore string - ExpireAfter string - OrderNo string - Reason string -} - -func shapeEvent(e *model.PayMembershipEvent) eventRow { - return eventRow{ - CreatedAt: fmtTime(e.CreatedAt), - UID: e.UID, - TypeText: eventTypeText[e.Type], - TypeClass: eventTypeClass[e.Type], - Days: e.Days, - ExpireBefore: fmtTime(e.ExpireBefore), - ExpireAfter: fmtTime(e.ExpireAfter), - OrderNo: fmtStrPtr(e.OrderNo), - Reason: fmtStrPtr(e.Reason), - } -} - -func (r *Router) pageMemberEvents(c *gin.Context) { - uid, err := strconv.ParseInt(c.Param("uid"), 10, 64) - if err != nil || uid < 0 { - render404(c, "uid 不合法") - return - } - page := queryPage(c) - evs, total, err := repo.ListEventsByUID(uid, pageSize, (page-1)*pageSize) - if err != nil { - slog.Error("[PAGE] list member events failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - items := make([]eventRow, 0, len(evs)) - for i := range evs { - items = append(items, shapeEvent(&evs[i])) - } - renderPage(c.Writer, "member_events", struct { - BaseData - Items []eventRow - pageNav - }{ - BaseData: newBase(c, "memberships", fmt.Sprintf("会员权益事件 · uid %d", uid), ""), - Items: items, - pageNav: buildPageNav(c, fmt.Sprintf("/memberships/%d/events", uid), url.Values{}, page, total), - }) -} diff --git a/app-pay-admin/internal/router/pages_orders.go b/app-pay-admin/internal/router/pages_orders.go deleted file mode 100644 index 56374131..00000000 --- a/app-pay-admin/internal/router/pages_orders.go +++ /dev/null @@ -1,255 +0,0 @@ -// Package router: order list + order detail pages. -package router - -import ( - "log/slog" - "net/http" - "strconv" - - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -type orderRow struct { - OrderNo string - UID int64 - ProductTitle string - SkuCode string - Amount string - Channel string - StatusText string - StatusClass string - PaidAt string - CreatedAt string -} - -func shapeOrder(o *model.PayOrder) orderRow { - return orderRow{ - OrderNo: o.OrderNo, - UID: o.UID, - ProductTitle: o.ProductTitle, - SkuCode: o.SkuCode, - Amount: fmtMoney(o.AmountCents), - Channel: o.Channel, - StatusText: orderStatusText[o.Status], - StatusClass: orderStatusClass[o.Status], - PaidAt: fmtTimePtr(o.PaidAt), - CreatedAt: fmtTime(o.CreatedAt), - } -} - -type ordersFilterView struct { - UID string - Status string - Channel string - Sku string - OrderNo string - TradeNo string - CreatedFrom string - CreatedTo string -} - -// readOrderFilters parses GET filters into repo predicates + the raw strings -// the template re-fills the form with. A parse failure is reported for the -// flash (the query still runs with the valid parts). -// -// Time fields render as : the picker submits -// "2006-01-02T15:04" (T separator), and a hand-typed space-separated value is -// normalized to "T" so the form refills into the picker correctly. -func readOrderFilters(c *gin.Context) (f repo.OrderFilter, v ordersFilterView, parseErr string) { - v = ordersFilterView{ - UID: c.Query("uid"), - Status: c.Query("status"), - Channel: c.Query("channel"), - Sku: c.Query("sku"), - OrderNo: c.Query("order_no"), - TradeNo: c.Query("trade_no"), - CreatedFrom: normalizeLocalDT(c.Query("created_from")), - CreatedTo: normalizeLocalDT(c.Query("created_to")), - } - f = repo.OrderFilter{ - Status: v.Status, - Channel: v.Channel, - SkuCode: v.Sku, - OrderNo: v.OrderNo, - TradeNo: v.TradeNo, - } - if v.UID != "" { - uid, err := strconv.ParseInt(v.UID, 10, 64) - if err != nil || uid < 0 { - parseErr = "uid 必须为非负整数" - return f, v, parseErr - } - f.UID, f.UIDSet = uid, true - } - if raw := c.Query("created_from"); raw != "" { - t, ok := parseFlexibleTime(raw) - if !ok { - parseErr = "「创建时间起」格式不正确(示例 2026-09-01 00:00)" - return f, v, parseErr - } - f.CreatedFrom = &t - } - if raw := c.Query("created_to"); raw != "" { - t, ok := parseFlexibleTime(raw) - if !ok { - parseErr = "「创建时间止」格式不正确(示例 2026-09-30 23:59)" - return f, v, parseErr - } - f.CreatedTo = &t - } - return f, v, "" -} - -// normalizeLocalDT converts a space-separated datetime to the T-separated -// form datetime-local inputs expect ("2026-09-01 20:00" -> "2026-09-01T20:00"). -func normalizeLocalDT(s string) string { - if len(s) > 10 && s[10] == ' ' { - return s[:10] + "T" + s[11:] - } - return s -} - -// filterValues collects the current filters for pagination links. -func (v ordersFilterView) values() map[string]string { - return map[string]string{ - "uid": v.UID, "status": v.Status, "channel": v.Channel, "sku": v.Sku, - "order_no": v.OrderNo, "trade_no": v.TradeNo, - "created_from": v.CreatedFrom, "created_to": v.CreatedTo, - } -} - -func (r *Router) pageOrders(c *gin.Context) { - b := newBase(c, "orders", "订单", "订单检索、状态追踪与回调流水(app_pay 库只读)") - f, fv, parseErr := readOrderFilters(c) - if parseErr != "" { - b.Flash = &flashData{Kind: "warn", Msg: parseErr} - } - page := queryPage(c) - f.Limit, f.Offset = pageSize, (page-1)*pageSize - - orders, total, err := repo.ListOrders(f) - if err != nil { - slog.Error("[PAGE] list orders failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - items := make([]orderRow, 0, len(orders)) - for i := range orders { - items = append(items, shapeOrder(&orders[i])) - } - renderPage(c.Writer, "orders", struct { - BaseData - F ordersFilterView - StatusOptions []selectOpt - ChannelOptions []selectOpt - Items []orderRow - pageNav - }{ - BaseData: b, - F: fv, - StatusOptions: statusOptions(fv.Status), - ChannelOptions: channelOptions(fv.Channel), - Items: items, - pageNav: buildPageNav(c, "/orders", urlValues(fv.values()), page, total), - }) -} - -type callbackRow struct { - CreatedAt string - Channel string - SignatureResult string - SigClass string - HandleResult string - HandleClass string - Message string - Payload string -} - -type orderDetailPage struct { - BaseData - Order orderDetailRow - Callbacks []callbackRow -} - -type orderDetailRow struct { - OrderNo string - UID int64 - ProductTitle string - SkuCode string - DurationDays int - Amount string - Channel string - ChannelTradeNo string - IdempotencyKey string - FailReason string - StatusText string - StatusClass string - Version int - CreatedAt string - PaidAt string - ClosedAt string - ExpiresAt string - UpdatedAt string -} - -func (r *Router) pageOrderDetail(c *gin.Context) { - order, err := repo.GetOrderByNo(c.Param("order_no")) - if err != nil { - slog.Error("[PAGE] get order failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - if order == nil { - render404(c, "订单不存在:"+c.Param("order_no")) - return - } - - callbacks, _, err := repo.ListCallbacks(order.OrderNo, 100, 0) - if err != nil { - slog.Error("[PAGE] list callbacks failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - cbRows := make([]callbackRow, 0, len(callbacks)) - for i := range callbacks { - l := &callbacks[i] - cbRows = append(cbRows, callbackRow{ - CreatedAt: fmtTime(l.CreatedAt), - Channel: l.Channel, - SignatureResult: l.SignatureResult, - SigClass: signatureClass[l.SignatureResult], - HandleResult: l.HandleResult, - HandleClass: handleResultClass[l.HandleResult], - Message: fmtStrPtr(l.Message), - Payload: fmtStrPtr(l.PayloadMasked), - }) - } - - renderPage(c.Writer, "order_detail", orderDetailPage{ - BaseData: newBase(c, "orders", "订单详情", ""), - Order: orderDetailRow{ - OrderNo: order.OrderNo, - UID: order.UID, - ProductTitle: order.ProductTitle, - SkuCode: order.SkuCode, - DurationDays: order.DurationDays, - Amount: fmtMoney(order.AmountCents), - Channel: order.Channel, - ChannelTradeNo: fmtStrPtr(order.ChannelTradeNo), - IdempotencyKey: fmtStrPtr(order.IdempotencyKey), - FailReason: fmtStrPtr(order.FailReason), - StatusText: orderStatusText[order.Status], - StatusClass: orderStatusClass[order.Status], - Version: order.Version, - CreatedAt: fmtTime(order.CreatedAt), - PaidAt: fmtTimePtr(order.PaidAt), - ClosedAt: fmtTimePtr(order.ClosedAt), - ExpiresAt: fmtTime(order.ExpiresAt), - UpdatedAt: fmtTime(order.UpdatedAt), - }, - Callbacks: cbRows, - }) -} diff --git a/app-pay-admin/internal/router/pages_products.go b/app-pay-admin/internal/router/pages_products.go deleted file mode 100644 index 4d4ec688..00000000 --- a/app-pay-admin/internal/router/pages_products.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package router: SKU catalog page (read-only in phase 1). -package router - -import ( - "log/slog" - "net/http" - "time" - - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -type productRow struct { - Code string - Title string - DurationDays int - Price string - Active bool - Sort int - UpdatedAt string -} - -func shapeProduct(p *model.PayProduct) productRow { - return productRow{ - Code: p.Code, - Title: p.Title, - DurationDays: p.DurationDays, - Price: fmtMoney(p.PriceCents), - Active: p.Status == "ACTIVE", - Sort: p.Sort, - UpdatedAt: fmtTime(p.UpdatedAt), - } -} - -func (r *Router) pageProducts(c *gin.Context) { - ps, err := repo.ListProducts() - if err != nil { - slog.Error("[PAGE] list products failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - _ = time.Now - items := make([]productRow, 0, len(ps)) - for i := range ps { - items = append(items, shapeProduct(&ps[i])) - } - renderPage(c.Writer, "products", struct { - BaseData - Items []productRow - }{ - BaseData: newBase(c, "products", "SKU 商品", "在售商品目录(只读;改价走 app-pay 运营改库流程)"), - Items: items, - }) -} diff --git a/app-pay-admin/internal/router/pages_test.go b/app-pay-admin/internal/router/pages_test.go deleted file mode 100644 index e6ed23a0..00000000 --- a/app-pay-admin/internal/router/pages_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package router - -// Tests for the SSR (html/template) page flow: form login/logout, page -// rendering, and the POST-redirect-GET form endpoints (grants / users). - -import ( - "bytes" - "net/http" - "net/http/httptest" - "net/url" - "os" - "strings" - "testing" - "time" - - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" -) - -// formPOST submits an application/x-www-form-urlencoded request. -func formPOST(h http.Handler, path string, form url.Values, headers map[string]string) *httptest.ResponseRecorder { - req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - for k, v := range headers { - req.Header.Set(k, v) - } - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - return w -} - -// ── form login / logout flow ──────────────────────────────────────── - -func TestSSRLoginFlow(t *testing.T) { - h := newTestRouter(t, "") - - // Wrong credentials re-render the login page with an error (no session). - form := url.Values{"username": {"admin"}, "password": {"nope"}} - w := formPOST(h, "/login", form, nil) - if w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte("用户名或密码不正确")) { - t.Fatalf("form login wrong: got %d, want 200 with error box", w.Code) - } - - // Correct credentials -> 303 redirect with session cookie. - form.Set("password", repo.DefaultAdminPass) - w = formPOST(h, "/login", form, nil) - if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/" { - t.Fatalf("form login: got %d Location=%q, want 303 /", w.Code, w.Header().Get("Location")) - } - if !strings.Contains(w.Header().Get("Set-Cookie"), webuiSessionCookie+"=") { - t.Fatalf("form login missing session cookie: %q", w.Header().Get("Set-Cookie")) - } - sid := sessionFromCookie(w) - - // The cookie unlocks pages... - ah := map[string]string{"Cookie": webuiSessionCookie + "=" + sid} - if w := doJSON(h, "GET", "/orders", "", ah); w.Code != http.StatusOK { - t.Fatalf("orders after form login: got %d", w.Code) - } - - // ...and POST /logout invalidates it. - w = formPOST(h, "/logout", url.Values{}, ah) - if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/login" { - t.Fatalf("form logout: got %d, want 303 /login", w.Code) - } - if w := doJSON(h, "GET", "/orders", "", ah); w.Code != http.StatusFound { - t.Fatalf("orders after logout: got %d, want 302 /login", w.Code) - } -} - -// ── page rendering with seeded data ───────────────────────────────── - -func seedSSRData(t *testing.T) { - t.Helper() - now := time.Now() - seedOrder(t, "PO-S1", 7001, "DELIVERED", "ALIPAY", 1800, now) - seedCallback(t, "PO-S1", "VALID", "ACCEPTED") - seedCallback(t, "PO-S1", "INVALID", "IGNORED") - lastOrder := "PO-S1" - if err := db.DB.Create(&model.PayMembership{ - UID: 7001, Tier: "VIP", ExpireAt: now.Add(10 * 24 * time.Hour), - LastOrderNo: &lastOrder, CreatedAt: now, UpdatedAt: now, - }).Error; err != nil { - t.Fatalf("seed membership: %v", err) - } - if err := db.DB.Create(&model.PayMembershipEvent{ - UID: 7001, Type: "ADMIN_GRANT", Days: 30, - ExpireBefore: now, ExpireAfter: now.Add(10 * 24 * time.Hour), - Reason: strPtr("[admin] 客诉补偿"), CreatedAt: now, - }).Error; err != nil { - t.Fatalf("seed event: %v", err) - } - if err := db.DB.Create(&model.PayProduct{ - Code: "VIP_MONTHLY", Title: "VIP 月卡", DurationDays: 30, - PriceCents: 1800, Status: "ACTIVE", CreatedAt: now, UpdatedAt: now, - }).Error; err != nil { - t.Fatalf("seed product: %v", err) - } -} - -func strPtr(s string) *string { return &s } - -func TestSSRPagesRender(t *testing.T) { - h := newTestRouter(t, "") - seedSSRData(t) - ah := adminHeaders(t, h) - - // Orders: table carries the seeded order (link) and status dot. - w := doJSON(h, "GET", "/orders?status=DELIVERED&uid=7001", "", ah) - if w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte("PO-S1")) || - !bytes.Contains(w.Body.Bytes(), []byte("st-green")) { - t.Fatalf("orders page: got %d (filters applied?) body=%s", w.Code, truncate(w.Body.String())) - } - - // Order detail: description list + callback rows. - w = doJSON(h, "GET", "/orders/PO-S1", "", ah) - if w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte("渠道回调流水")) || - !bytes.Contains(w.Body.Bytes(), []byte("¥18.00")) { - t.Fatalf("order detail page: got %d body=%s", w.Code, truncate(w.Body.String())) - } - - // Unknown order -> shell 404 with flash. - w = doJSON(h, "GET", "/orders/PO-NOPE", "", ah) - if w.Code != http.StatusNotFound || !bytes.Contains(w.Body.Bytes(), []byte("订单不存在")) { - t.Fatalf("unknown order page: got %d, want 404 shell", w.Code) - } - - // datetime-local filter: picker format ("2026-09-01T00:00") parses without - // a warn flash and refills the input in picker-compatible form. - w = doJSON(h, "GET", "/orders?created_from="+url.QueryEscape("2026-09-01T00:00"), "", ah) - if w.Code != http.StatusOK || bytes.Contains(w.Body.Bytes(), []byte("格式不正确")) || - !bytes.Contains(w.Body.Bytes(), []byte(`name="created_from" value="2026-09-01T00:00"`)) { - t.Fatalf("datetime-local filter parse/refill failed") - } - - // Memberships / member events / events / products / grants. - for _, path := range []string{"/memberships", "/memberships/7001/events", "/events?type=ADMIN_GRANT", "/products", "/grants"} { - if w := doJSON(h, "GET", path, "", ah); w.Code != http.StatusOK { - t.Fatalf("GET %s: got %d, want 200", path, w.Code) - } - } - if w := doJSON(h, "GET", "/memberships?uid=7001", "", ah); w.Code != http.StatusOK || - !bytes.Contains(w.Body.Bytes(), []byte("生效中")) { - t.Fatalf("membership lookup page: got %d", w.Code) - } - // Admin grant page must render the full form (guards against partial - // template execution, e.g. field-promotion failures). - if w := doJSON(h, "GET", "/grants", "", ah); w.Code != http.StatusOK || - !bytes.Contains(w.Body.Bytes(), []byte("确认补偿开通")) { - t.Fatalf("grants page missing grant form (partial render?)") - } -} - -func truncate(s string) string { - if len(s) > 400 { - return s[:400] - } - return s -} - -// ── grant form (POST-redirect-GET) ────────────────────────────────── - -func TestSSRGrantForm(t *testing.T) { - h, auditPath := newTestRouterFull(t, "") - ah := adminHeaders(t, h) - - form := url.Values{"uid": {"8001"}, "duration_days": {"14"}, "reason": {"工单补偿"}} - w := formPOST(h, "/grants", form, ah) - loc := w.Header().Get("Location") - if w.Code != http.StatusSeeOther || !strings.Contains(loc, "/grants?ok=") { - t.Fatalf("grant form: got %d Location=%q, want 303 /grants?ok=...", w.Code, loc) - } - m, err := repo.GetMembershipByUID(8001) - if err != nil || m == nil { - t.Fatalf("membership after form grant: %v %v", m, err) - } - if b, _ := os.ReadFile(auditPath); !bytes.Contains(b, []byte(`"operator":"admin"`)) { - t.Fatalf("audit jsonl missing grant entry: %s", b) - } - - // Invalid input -> redirect with err flash, nothing written. - form.Set("duration_days", "0") - w = formPOST(h, "/grants", form, ah) - if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "err=") { - t.Fatalf("grant form invalid: got %d Location=%q", w.Code, w.Header().Get("Location")) - } - if m2, _ := repo.GetMembershipByUID(8002); m2 != nil { - t.Fatal("invalid grant must not write membership") - } - - // Viewer is rejected with an err flash. - vh := viewerHeaders(t, h) - w = formPOST(h, "/grants", url.Values{"uid": {"8003"}, "duration_days": {"7"}, "reason": {"x"}}, vh) - if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "err=") { - t.Fatalf("viewer grant form: got %d Location=%q", w.Code, w.Header().Get("Location")) - } -} - -// ── user management forms ─────────────────────────────────────────── - -func TestSSRUserForms(t *testing.T) { - h := newTestRouter(t, "") - ah := adminHeaders(t, h) - - // Create viewer via form. - w := formPOST(h, "/users", - url.Values{"username": {"ops2"}, "password": {"ops-pass-123"}, "role": {"viewer"}}, ah) - if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "ok=") { - t.Fatalf("user create form: got %d Location=%q", w.Code, w.Header().Get("Location")) - } - if u, _ := repo.GetUserByUsername("ops2"); u == nil || u.Role != repo.RoleViewer { - t.Fatal("viewer account not created") - } - - // Duplicate -> err flash. - w = formPOST(h, "/users", - url.Values{"username": {"ops2"}, "password": {"ops-pass-123"}}, ah) - if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "err=") { - t.Fatalf("duplicate user form: got %d Location=%q", w.Code, w.Header().Get("Location")) - } - - // Users page renders the accounts (admin sees tab + rows). - if w := doJSON(h, "GET", "/users", "", ah); w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte("ops2")) { - t.Fatalf("users page: got %d", w.Code) - } - - // Viewer cannot open the users page (redirected with err flash). - vh := viewerHeaders(t, h) - if w := doJSON(h, "GET", "/users", "", vh); w.Code != http.StatusSeeOther { - t.Fatalf("viewer users page: got %d, want 303", w.Code) - } -} - -// viewerHeaders logs in as the seeded-by-test viewer account (created via the -// admin API) and returns cookie headers. -func viewerHeaders(t *testing.T, h http.Handler) map[string]string { - t.Helper() - ah := adminHeaders(t, h) - if w := doJSON(h, "POST", "/api/users", `{"username":"vw","password":"vw-pass-123","role":"viewer"}`, ah); w.Code != http.StatusOK { - t.Fatalf("seed viewer: got %d, body %s", w.Code, w.Body.String()) - } - w := doJSON(h, "POST", "/api/login", `{"username":"vw","password":"vw-pass-123"}`, nil) - if w.Code != http.StatusOK { - t.Fatalf("viewer login: got %d", w.Code) - } - return map[string]string{"Cookie": webuiSessionCookie + "=" + sessionFromCookie(w)} -} diff --git a/app-pay-admin/internal/router/pages_users.go b/app-pay-admin/internal/router/pages_users.go deleted file mode 100644 index de71a018..00000000 --- a/app-pay-admin/internal/router/pages_users.go +++ /dev/null @@ -1,161 +0,0 @@ -// Package router: console account page + form submits (admin only). -package router - -import ( - "log/slog" - "net/http" - "strconv" - "strings" - - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -type userRow struct { - ID int64 - Username string - Role string - IsAdmin bool - CreatedAt string - Deletable bool -} - -func requireAdminPage(c *gin.Context) (webuiSession, bool) { - s, ok := currentUser(c) - if !ok || s.Role != repo.RoleAdmin { - redirectFlash(c, "/orders", "err", "需要 admin 角色") - return s, false - } - return s, true -} - -func (r *Router) pageUsers(c *gin.Context) { - s, ok := requireAdminPage(c) - if !ok { - return - } - us, err := repo.ListUsers() - if err != nil { - slog.Error("[PAGE] list users failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - admins, _ := repo.CountUsersByRole(repo.RoleAdmin) - items := make([]userRow, 0, len(us)) - for _, u := range us { - deletable := u.ID != s.UserID - if u.Role == repo.RoleAdmin && admins <= 1 { - deletable = false // 最后一个 admin 不可删 - } - items = append(items, userRow{ - ID: u.ID, - Username: u.Username, - Role: u.Role, - IsAdmin: u.Role == repo.RoleAdmin, - CreatedAt: fmtTime(u.CreatedAt), - Deletable: deletable, - }) - } - renderPage(c.Writer, "users", struct { - BaseData - Items []userRow - }{ - BaseData: newBase(c, "users", "账户", "控制台账户与角色管理(payadmin_user,仅 admin 可见)"), - Items: items, - }) -} - -func (r *Router) handleUserCreate(c *gin.Context) { - s, ok := requireAdminPage(c) - if !ok { - return - } - username := strings.TrimSpace(c.PostForm("username")) - password := c.PostForm("password") - role := c.PostForm("role") - if len(username) < 2 || len(username) > 64 { - redirectFlash(c, "/users", "err", "用户名需 2–64 字符") - return - } - if len(password) < 8 || len(password) > 128 { - redirectFlash(c, "/users", "err", "密码需 8–128 位") - return - } - if role == "" { - role = repo.RoleViewer - } - if role != repo.RoleAdmin && role != repo.RoleViewer { - redirectFlash(c, "/users", "err", "角色必须是 admin 或 viewer") - return - } - u, err := repo.CreateUser(username, password, role) - if err == repo.ErrUserExists { - redirectFlash(c, "/users", "err", "用户名已存在:%s", username) - return - } - if err != nil { - slog.Error("[USER] create failed", "err", err, "operator", s.Username) - redirectFlash(c, "/users", "err", "创建失败:%v", err) - return - } - slog.Info("[USER] created", "operator", s.Username, "username", u.Username, "role", u.Role) - redirectFlash(c, "/users", "ok", "账户已创建:%s(%s)", u.Username, u.Role) -} - -func (r *Router) handleUserPassword(c *gin.Context) { - s, ok := requireAdminPage(c) - if !ok { - return - } - id, err := strconv.ParseInt(c.Param("user_id"), 10, 64) - if err != nil { - redirectFlash(c, "/users", "err", "user id 不合法") - return - } - password := c.PostForm("password") - if len(password) < 8 || len(password) > 128 { - redirectFlash(c, "/users", "err", "密码需 8–128 位") - return - } - if err := repo.SetUserPassword(id, password); err != nil { - redirectFlash(c, "/users", "err", "改密失败:%v", err) - return - } - slog.Info("[USER] password changed", "operator", s.Username, "user_id", id) - redirectFlash(c, "/users", "ok", "密码已更新") -} - -func (r *Router) handleUserDelete(c *gin.Context) { - s, ok := requireAdminPage(c) - if !ok { - return - } - id, err := strconv.ParseInt(c.Param("user_id"), 10, 64) - if err != nil { - redirectFlash(c, "/users", "err", "user id 不合法") - return - } - if id == s.UserID { - redirectFlash(c, "/users", "err", "不能删除自己的账户") - return - } - target, err := repo.GetUserByID(id) - if err != nil || target == nil { - redirectFlash(c, "/users", "err", "账户不存在") - return - } - if target.Role == repo.RoleAdmin { - admins, _ := repo.CountUsersByRole(repo.RoleAdmin) - if admins <= 1 { - redirectFlash(c, "/users", "err", "不能删除最后一个 admin") - return - } - } - if err := repo.DeleteUser(id); err != nil { - redirectFlash(c, "/users", "err", "删除失败:%v", err) - return - } - slog.Info("[USER] deleted", "operator", s.Username, "username", target.Username) - redirectFlash(c, "/users", "ok", "账户已删除:%s", target.Username) -} diff --git a/app-pay-admin/internal/router/products.go b/app-pay-admin/internal/router/products.go deleted file mode 100644 index fa77174d..00000000 --- a/app-pay-admin/internal/router/products.go +++ /dev/null @@ -1,39 +0,0 @@ -// Package router: SKU catalog (pay_product, read-only in phase 1 — price / -// status changes remain an app-pay-side operational task). -package router - -import ( - "net/http" - "time" - - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -func (r *Router) apiListProducts(c *gin.Context) { - ps, err := repo.ListProducts() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - items := make([]gin.H, 0, len(ps)) - for i := range ps { - items = append(items, productView(&ps[i])) - } - c.JSON(http.StatusOK, gin.H{"items": items}) -} - -func productView(p *model.PayProduct) gin.H { - return gin.H{ - "code": p.Code, - "title": p.Title, - "duration_days": p.DurationDays, - "price_cents": p.PriceCents, - "status": p.Status, - "sort": p.Sort, - "created_at": p.CreatedAt.Format(time.RFC3339), - "updated_at": p.UpdatedAt.Format(time.RFC3339), - } -} diff --git a/app-pay-admin/internal/router/refunds.go b/app-pay-admin/internal/router/refunds.go deleted file mode 100644 index f6b585e6..00000000 --- a/app-pay-admin/internal/router/refunds.go +++ /dev/null @@ -1,164 +0,0 @@ -// Package router: 退款复核工作台(承接 app-pay 的 maker-checker)。 -// -// 列表 = 直连 pay_refund_approval 表;approve/reject = 经 appops 客户端调 -// app-pay 的 internal 端点 —— 资金写只发生在 app-pay(审计 JSONL / 状态机 / -// 审批表一致性),本控制台只携带登录身份作为 operator(checker),替代 -// curl 自报身份。整组功能 admin only,且 PAYADMIN__APP_PAY__OPS_KEY 未配置 -// 时整组隐藏(404),不可被探测。 -package router - -import ( - "log/slog" - "net/http" - "strings" - - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -// refundReviewEnabled reports whether the workbench is wired (ops key present -// + master switch on). -func (r *Router) refundReviewEnabled() bool { - return r.appops != nil && r.appops.Enabled() && r.cfg.AppPay.RefundReviewEnabled -} - -// refundGate aborts with 404 when the workbench is not wired (stealth, same -// posture as app-pay's disabled test endpoints). -func (r *Router) refundGate() gin.HandlerFunc { - return func(c *gin.Context) { - if !r.refundReviewEnabled() { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"detail": "refund review not configured"}) - return - } - c.Next() - } -} - -// ── API(scripts / master-token callers)──────────────────────────── - -// GET /api/refunds?status=PENDING(直读审批表) -func (r *Router) apiListRefunds(c *gin.Context) { - status := strings.ToUpper(strings.TrimSpace(c.DefaultQuery("status", "PENDING"))) - if status == "ALL" { - status = "" - } - limit, offset := pagination(c, 50, 200) - rows, total, err := repo.ListRefundApprovals(repo.RefundApprovalFilter{ - Status: status, Limit: limit, Offset: offset, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "query failed: " + err.Error()}) - return - } - items := make([]gin.H, 0, len(rows)) - for i := range rows { - items = append(items, gin.H{ - "order_no": rows[i].OrderNo, - "maker": rows[i].Maker, - "maker_reason": rows[i].MakerReason, - "status": rows[i].Status, - "created_at": timePtr(&rows[i].CreatedAt), - }) - } - c.JSON(http.StatusOK, gin.H{"total": total, "items": items}) -} - -// POST /api/refunds/:order_no/approve {"reason": "..."} — checker = 登录身份 -func (r *Router) apiApproveRefund(c *gin.Context) { - r.apiRefundAction(c, true) -} - -// POST /api/refunds/:order_no/reject -func (r *Router) apiRejectRefund(c *gin.Context) { - r.apiRefundAction(c, false) -} - -func (r *Router) apiRefundAction(c *gin.Context, confirm bool) { - orderNo := c.Param("order_no") - var req struct { - Reason string `json:"reason"` - } - _ = c.ShouldBindJSON(&req) - operator := operatorOf(c) - if err := r.appops.ConfirmRefundOrReject(c.Request.Context(), orderNo, operator, req.Reason, confirm); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true, "order_no": orderNo, "confirmed": confirm, "checker": operator}) -} - -// ── SSR page + forms ──────────────────────────────────────────────── - -type refundRow struct { - OrderNo string - Maker string - MakerReason string - Status string - CreatedAt string -} - -func (r *Router) pageRefunds(c *gin.Context) { - b := newBase(c, "refunds", "退款复核", - "退款双人复核(maker-checker):列表为审批单;确认/驳回经 app-pay 执行(checker != maker 由服务端强制)") - - status := strings.ToUpper(strings.TrimSpace(c.DefaultQuery("status", "PENDING"))) - if status == "ALL" { - status = "" - } - rows, total, err := repo.ListRefundApprovals(repo.RefundApprovalFilter{Status: status, Limit: 50, Offset: 0}) - if err != nil { - slog.Error("[PAGE] list refund approvals failed", "err", err) - http.Error(c.Writer, "查询失败", http.StatusInternalServerError) - return - } - items := make([]refundRow, 0, len(rows)) - for i := range rows { - items = append(items, refundRow{ - OrderNo: rows[i].OrderNo, - Maker: rows[i].Maker, - MakerReason: rows[i].MakerReason, - Status: rows[i].Status, - CreatedAt: fmtTime(rows[i].CreatedAt), - }) - } - renderPage(c.Writer, "refunds", struct { - BaseData - Items []refundRow - Total int64 - Status string - Statuses []string - }{ - BaseData: b, - Items: items, - Total: total, - Status: status, - Statuses: []string{"PENDING", "APPROVED", "REJECTED"}, - }) -} - -// handleRefundApproveForm — SSR 表单:approve(confirm=true) -func (r *Router) handleRefundApproveForm(c *gin.Context) { - orderNo := c.PostForm("order_no") - reason := strings.TrimSpace(c.PostForm("reason")) - if reason == "" { - reason = "复核通过" - } - operator := operatorOf(c) - if err := r.appops.ConfirmRefundOrReject(c.Request.Context(), orderNo, operator, reason, true); err != nil { - redirectFlash(c, "/refunds", "err", "复核失败:%v", err) - return - } - redirectFlash(c, "/refunds", "ok", "已复核通过并执行退款:%s", orderNo) -} - -// handleRefundRejectForm — SSR 表单:reject -func (r *Router) handleRefundRejectForm(c *gin.Context) { - orderNo := c.PostForm("order_no") - operator := operatorOf(c) - if err := r.appops.RejectRefund(c.Request.Context(), orderNo, operator); err != nil { - redirectFlash(c, "/refunds", "err", "驳回失败:%v", err) - return - } - redirectFlash(c, "/refunds", "ok", "已驳回退款申请:%s", orderNo) -} diff --git a/app-pay-admin/internal/router/refunds_test.go b/app-pay-admin/internal/router/refunds_test.go deleted file mode 100644 index fc855e27..00000000 --- a/app-pay-admin/internal/router/refunds_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package router - -// Tests for the refund-review workbench : stealth 404 when the ops key is -// not configured, admin-only gating, and approve/reject proxying to the fake -// app-pay upstream (the money write happens in app-pay, not here). - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "sync/atomic" - "testing" - "time" - - "app-pay-admin/internal/appops" - "app-pay-admin/internal/config" - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" - "app-pay-admin/internal/service" -) - -// newRefundTestRouter builds a router with the workbench wired to a fake -// app-pay upstream and seeds one PENDING approval for order PO1. -func newRefundTestRouter(t *testing.T, configured bool, upstreamHits *atomic.Int32) (http.Handler, *httptest.Server) { - t.Helper() - setupTestDB(t) - if err := repo.EnsureDefaultAdmin(); err != nil { - t.Fatalf("seed admin: %v", err) - } - if err := db.DB.Create(&model.PayRefundApproval{ - OrderNo: "PO1", Maker: "ops-a", MakerReason: "客诉补偿", - Status: "PENDING", CreatedAt: time.Now(), - }).Error; err != nil { - t.Fatalf("seed approval: %v", err) - } - - // fake app-pay upstream: asserts the ops key rides on every call - fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamHits.Add(1) - if r.Header.Get("apikey") != "ops-key" { - w.WriteHeader(http.StatusUnauthorized) - return - } - w.Header().Set("Content-Type", "application/json") - switch { - case r.URL.Path == "/internal/pay/refund-requests": - _ = json.NewEncoder(w).Encode([]map[string]any{{ - "order_no": "PO1", "maker": "ops-a", "maker_reason": "客诉补偿", - "status": "PENDING", "created_at": "2026-10-18T00:00:00Z", - }}) - default: - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) - } - })) - t.Cleanup(fake.Close) - - cfg := &config.Config{} - cfg.WebUI.Enabled = true - cfg.AppPay.BaseURL = fake.URL - cfg.AppPay.OpsKey = "ops-key" - cfg.AppPay.RefundReviewEnabled = configured - audit := service.NewAudit(filepath.Join(t.TempDir(), "a.jsonl")) - t.Cleanup(func() { _ = audit.Close() }) - return New(cfg, service.NewGrantService(audit, service.GrantCaps{}), - appops.New(cfg.AppPay.BaseURL, cfg.AppPay.OpsKey, 5)), fake -} - -func TestRefundReviewStealthWhenNotConfigured(t *testing.T) { - // ops key 空 = 整组 404(不可探测),即使 admin 已登录 - h, _ := newRefundTestRouter(t, false, &atomic.Int32{}) - w := doJSON(h, "GET", "/api/refunds", "", adminHeaders(t, h)) - if w.Code != http.StatusNotFound { - t.Fatalf("unconfigured refund list: got %d, want 404", w.Code) - } -} - -func TestRefundWorkbenchListAndApprove(t *testing.T) { - var hits atomic.Int32 - h, _ := newRefundTestRouter(t, true, &hits) - admin := adminHeaders(t, h) - - // list: reads the approval table - w := doJSON(h, "GET", "/api/refunds", "", admin) - if w.Code != http.StatusOK { - t.Fatalf("list: got %d, body %s", w.Code, w.Body.String()) - } - var out struct { - Total int64 `json:"total"` - Items []struct { - OrderNo string `json:"order_no"` - Status string `json:"status"` - } `json:"items"` - } - if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { - t.Fatalf("parse list: %v", err) - } - if out.Total != 1 || len(out.Items) != 1 || out.Items[0].OrderNo != "PO1" { - t.Fatalf("list content: %+v", out) - } - - // approve: proxies to app-pay with confirm=true and operator identity - w = doJSON(h, "POST", "/api/refunds/PO1/approve", `{"reason":"复核通过"}`, admin) - if w.Code != http.StatusOK { - t.Fatalf("approve: got %d, body %s", w.Code, w.Body.String()) - } - if hits.Load() < 1 { // approve 必须打到 fake app-pay(list 走 DB,不经上游) - t.Fatalf("upstream hits = %d, want >= 1 (approve POST)", hits.Load()) - } - - // reject: same proxy path - w = doJSON(h, "POST", "/api/refunds/PO1/reject", "{}", admin) - if w.Code != http.StatusOK { - t.Fatalf("reject: got %d, body %s", w.Code, w.Body.String()) - } -} - -func TestRefundReviewRejectsViewerRole(t *testing.T) { - // viewer 不能进复核工作台(admin-only):approve 返回 403 - h, _ := newRefundTestRouter(t, true, &atomic.Int32{}) - // create a viewer account directly - if _, err := repo.CreateUser("viewer1", "viewer-pass", repo.RoleViewer); err != nil { - t.Fatalf("seed viewer: %v", err) - } - w := doJSON(h, "POST", "/api/login", `{"username":"viewer1","password":"viewer-pass"}`, nil) - if w.Code != http.StatusOK { - t.Fatalf("viewer login: %d", w.Code) - } - sid := sessionFromCookie(w) - w = doJSON(h, "POST", "/api/refunds/PO1/approve", "{}", map[string]string{ - "Cookie": "payadmin_session=" + sid, - }) - if w.Code != http.StatusForbidden { - t.Fatalf("viewer approve: got %d, want 403", w.Code) - } -} diff --git a/app-pay-admin/internal/router/router.go b/app-pay-admin/internal/router/router.go deleted file mode 100644 index 69b1ef31..00000000 --- a/app-pay-admin/internal/router/router.go +++ /dev/null @@ -1,112 +0,0 @@ -// Package router wires the Gin engine and HTTP handlers for the pay admin -// console. -// -// File layout (one concern per file): -// -// router.go — engine assembly: New / Router / CORS / security headers -// webui.go — authenticator (login/session/throttle) + /api/* registration -// console.go — page gating: /login + gated SPA at / + noHTMLFS -// orders.go — order list/detail/callbacks (pay_order, pay_callback_log) -// memberships.go — membership + entitlement events (pay_membership[_event]) -// products.go — SKU catalog (pay_product, read-only) -// grant.go — POST /api/grants (membership grant, admin-only) -// users.go — console account management (payadmin_user, admin-only) -// -// Unlike app-task there are NO APISIX-facing endpoints: the whole service is a -// loopback admin console gated by its own account store. -package router - -import ( - "strings" - - "app-pay-admin/internal/appops" - "app-pay-admin/internal/config" - "app-pay-admin/internal/logger" - "app-pay-admin/internal/service" - - "github.com/gin-gonic/gin" - "net/http" -) - -func New(cfg *config.Config, grants *service.GrantService, appops *appops.Client) *gin.Engine { - if !cfg.App.Debug { - gin.SetMode(gin.ReleaseMode) - } - e := gin.New() - // Trust no proxy: the login throttle keys on c.ClientIP(), and gin's - // default (trust everything) lets any client spoof X-Forwarded-For to - // sidestep it. With no trusted proxy the peer address is always used. - _ = e.SetTrustedProxies(nil) - e.Use(logger.GinLogger()) - e.Use(logger.GinRecovery()) - e.Use(securityHeaders()) - e.Use(corsMiddleware(cfg.Security.CORS.AllowOrigins)) - - r := &Router{cfg: cfg, grants: grants, appops: appops} - r.registerRoutes(e) - return e -} - -type Router struct { - cfg *config.Config - grants *service.GrantService - appops *appops.Client -} - -func (r *Router) registerRoutes(e *gin.Engine) { - e.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "app-pay-admin"}) - }) - - // Admin console (single binary): server-rendered pages (html/template) + - // the /api/* JSON group for scripts/master-token callers. Disabled - // entirely when webui.enabled=false. - if r.cfg.WebUI.Enabled { - auth := newWebuiAuthenticator(r.cfg.WebUI.Token, r.cfg.WebUI.SessionTTLMinutes) - r.registerPages(e, auth) - r.registerWebuiRoutes(e, auth) - } -} - -func corsMiddleware(allowOrigins []string) gin.HandlerFunc { - allowed := make(map[string]bool, len(allowOrigins)) - for _, o := range allowOrigins { - allowed[o] = true - } - return func(c *gin.Context) { - origin := c.GetHeader("Origin") - if allowed[origin] { - c.Header("Access-Control-Allow-Origin", origin) - c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") - // X-WebUI-Token is the console's own header; without it here a - // cross-origin browser client fails CORS preflight. - c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id, X-WebUI-Token") - c.Header("Access-Control-Max-Age", "600") - // The origin is reflected, so caches must not share responses - // across origins. - c.Header("Vary", "Origin") - } - if c.Request.Method == http.MethodOptions { - c.AbortWithStatus(http.StatusNoContent) - return - } - c.Next() - } -} - -// securityHeaders adds baseline hardening for the admin console: no framing -// (clickjacking), no MIME sniffing, no referrer leakage, and no-store so -// payment data never lands in shared caches. -func securityHeaders() gin.HandlerFunc { - return func(c *gin.Context) { - c.Header("X-Content-Type-Options", "nosniff") - c.Header("X-Frame-Options", "DENY") - c.Header("Content-Security-Policy", "frame-ancestors 'none'") - c.Header("Referrer-Policy", "no-referrer") - if strings.HasPrefix(c.Request.URL.Path, "/api/") { - c.Header("Cache-Control", "no-store") - } - c.Next() - } -} diff --git a/app-pay-admin/internal/router/users.go b/app-pay-admin/internal/router/users.go deleted file mode 100644 index 68d30ac1..00000000 --- a/app-pay-admin/internal/router/users.go +++ /dev/null @@ -1,117 +0,0 @@ -// Package router: console account management (payadmin_user, admin only). -package router - -import ( - "log/slog" - "net/http" - "strconv" - "time" - - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" -) - -func (r *Router) apiListUsers(c *gin.Context) { - us, err := repo.ListUsers() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - out := make([]gin.H, 0, len(us)) - for _, u := range us { - out = append(out, gin.H{ - "id": u.ID, - "username": u.Username, - "role": u.Role, - "created_at": u.CreatedAt.Format(time.RFC3339), - }) - } - c.JSON(http.StatusOK, gin.H{"users": out}) -} - -func (r *Router) apiCreateUser(c *gin.Context) { - var req struct { - Username string `json:"username" binding:"required,min=2,max=64"` - Password string `json:"password" binding:"required,min=8,max=128"` - Role string `json:"role"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request: " + err.Error()}) - return - } - if req.Role == "" { - req.Role = repo.RoleViewer - } - if req.Role != repo.RoleAdmin && req.Role != repo.RoleViewer { - c.JSON(http.StatusBadRequest, gin.H{"detail": "role must be admin or viewer"}) - return - } - u, err := repo.CreateUser(req.Username, req.Password, req.Role) - if err == repo.ErrUserExists { - c.JSON(http.StatusConflict, gin.H{"detail": "username already exists"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "create user failed: " + err.Error()}) - return - } - slog.Info("[USER] created", "operator", operatorOf(c), "username", u.Username, "role", u.Role) - c.JSON(http.StatusOK, gin.H{"id": u.ID, "username": u.Username, "role": u.Role}) -} - -func (r *Router) apiSetUserPassword(c *gin.Context) { - id, err := strconv.ParseInt(c.Param("user_id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid user id"}) - return - } - var req struct { - Password string `json:"password" binding:"required,min=8,max=128"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request: " + err.Error()}) - return - } - if err := repo.SetUserPassword(id, req.Password); err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) - return - } - slog.Info("[USER] password changed", "operator", operatorOf(c), "user_id", id) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -func (r *Router) apiDeleteUser(c *gin.Context) { - id, err := strconv.ParseInt(c.Param("user_id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid user id"}) - return - } - me, ok := currentUser(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"}) - return - } - if me.UserID == id { - c.JSON(http.StatusBadRequest, gin.H{"detail": "cannot delete your own account"}) - return - } - target, err := repo.GetUserByID(id) - if err != nil || target == nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) - return - } - if target.Role == repo.RoleAdmin { - admins, _ := repo.CountUsersByRole(repo.RoleAdmin) - if admins <= 1 { - c.JSON(http.StatusBadRequest, gin.H{"detail": "cannot delete the last admin"}) - return - } - } - if err := repo.DeleteUser(id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - slog.Info("[USER] deleted", "operator", me.Username, "username", target.Username) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} diff --git a/app-pay-admin/internal/router/webui.go b/app-pay-admin/internal/router/webui.go deleted file mode 100644 index 296ee57d..00000000 --- a/app-pay-admin/internal/router/webui.go +++ /dev/null @@ -1,387 +0,0 @@ -// Package router: admin console API gate. -// -// The webui is served by the same Gin process (single binary, see web/); -// /api/* endpoints back it. These are admin endpoints that see ALL users' -// payment data, so they are gated by the console account store (bcrypt -// username/password, seeded default admin) plus an optional master API token. -package router - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/hex" - "net/http" - "strconv" - "sync" - "time" - - "app-pay-admin/internal/repo" - - "github.com/gin-gonic/gin" - "golang.org/x/crypto/bcrypt" -) - -const webuiVersion = "0.2.0" - -const ( - // Brute-force throttle: max failed credentials per client IP per window, - // shared by the login endpoint and the /api/* gate so the limit cannot be - // bypassed by hammering any other endpoint. - webuiMaxFails = 10 - webuiFailWindow = time.Minute - webuiDefaultTTL = 12 * time.Hour - webuiSessionBytes = 32 - - // Browser sessions ride an HttpOnly cookie so the server can gate the - // console PAGE itself (redirect to /login before any app HTML is sent); - // programmatic clients use the session id / master token via headers. - webuiSessionCookie = "payadmin_session" - webuiCtxUser = "webui_user" // gin context key for the authed identity -) - -// webuiSession is the identity attached to a browser/API session: it always -// carries a username and role so handlers can authorize and audit. -type webuiSession struct { - UserID int64 - Username string - Role string // admin / viewer - Expiry time.Time -} - -// registerWebuiRoutes mounts the admin API behind the auth gate. The console -// ALWAYS requires login (a default admin account is seeded at startup), so the -// gate is unconditional; webui.enabled=false disables the whole console. -func (r *Router) registerWebuiRoutes(e *gin.Engine, auth *webuiAuthenticator) { - // Login/logout sit OUTSIDE the gate: login must be reachable without - // credentials (it IS the credential check), and logout authenticates with - // the very session it invalidates. - e.POST("/api/login", auth.login) - e.POST("/api/logout", auth.logout) - - api := e.Group("/api", auth.middleware()) - api.GET("/info", r.apiInfo) - - // Pay domain queries (viewer and above). - api.GET("/orders", r.apiListOrders) - api.GET("/orders/:order_no", r.apiOrderDetail) - api.GET("/orders/:order_no/callbacks", r.apiOrderCallbacks) - api.GET("/memberships", r.apiListMemberships) - api.GET("/memberships/:uid/events", r.apiMemberEvents) - api.GET("/events", r.apiListEvents) - api.GET("/products", r.apiListProducts) - - // Money-touching write: admin only. - api.POST("/grants", requireAdmin(), r.apiGrant) - - // Refund-review workbench : admin only; whole group 404s when - // PAYADMIN__APP_PAY__OPS_KEY is not configured. - refunds := api.Group("/refunds", requireAdmin(), r.refundGate()) - refunds.GET("", r.apiListRefunds) - refunds.POST("/:order_no/approve", r.apiApproveRefund) - refunds.POST("/:order_no/reject", r.apiRejectRefund) - - // Account management: admin only. - users := api.Group("/users", requireAdmin()) - users.GET("", r.apiListUsers) - users.POST("", r.apiCreateUser) - users.POST("/:user_id/password", r.apiSetUserPassword) - users.DELETE("/:user_id", r.apiDeleteUser) -} - -// webuiAuthenticator gates the admin console. Two credential kinds are -// accepted, both presented via X-WebUI-Token / Authorization: Bearer: -// - user sessions issued by POST /api/login (username + password), stored in -// an HttpOnly cookie for the browser and/or a session id for API clients; -// - the optional master token (webui.token, constant-time compare) as an -// API-key fallback for scripts — it authenticates as admin. -// -// Failed attempts are throttled per client IP across ALL /api/* endpoints. -type webuiAuthenticator struct { - masterToken string - ttl time.Duration - - mu sync.Mutex - sessions map[string]webuiSession // session id -> identity - fails map[string]int // client ip -> failures in current window - windowStart time.Time -} - -func newWebuiAuthenticator(token string, ttlMinutes int) *webuiAuthenticator { - ttl := webuiDefaultTTL - if ttlMinutes > 0 { - ttl = time.Duration(ttlMinutes) * time.Minute - } - return &webuiAuthenticator{ - masterToken: token, - ttl: ttl, - sessions: make(map[string]webuiSession), - fails: make(map[string]int), - windowStart: time.Now(), - } -} - -// middleware gates /api/* on a valid credential and stashes the identity for -// downstream handlers. Any failed check counts toward the per-IP throttle; a -// success clears it. -func (a *webuiAuthenticator) middleware() gin.HandlerFunc { - return func(c *gin.Context) { - ip := c.ClientIP() - if !a.allow(ip) { - a.throttleAbort(c) - return - } - sess, ok := a.authenticate(a.extractToken(c)) - if !ok { - a.fail(ip) - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized (login required)"}) - return - } - a.reset(ip) - c.Set(webuiCtxUser, sess) - c.Next() - } -} - -// login exchanges credentials for a fresh session. Accepts either a username + -// password (console login) or the master token (script/API fallback). The -// session is issued BOTH as an HttpOnly+SameSite=Strict cookie (browser flow) -// and in the JSON body (API flow, presented via X-WebUI-Token / Bearer). -func (a *webuiAuthenticator) login(c *gin.Context) { - var req struct { - Username string `json:"username"` - Password string `json:"password"` - Token string `json:"token"` - } - _ = c.ShouldBindJSON(&req) - ip := c.ClientIP() - if !a.allow(ip) { - a.throttleAbort(c) - return - } - - var sess webuiSession - var ok bool - switch { - case req.Username != "" && req.Password != "": - sess, ok = a.verifyUser(req.Username, req.Password) - case req.Token != "": - sess, ok = a.authenticate(req.Token) - default: - // Empty credentials are a fresh visitor, not an attack: reject - // without burning the throttle budget. - c.JSON(http.StatusUnauthorized, gin.H{"detail": "username/password or token required"}) - return - } - if !ok { - a.fail(ip) - c.JSON(http.StatusUnauthorized, gin.H{"detail": "invalid credentials"}) - return - } - a.reset(ip) - - sid, err := a.newSession(sess) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "issue session failed"}) - return - } - http.SetCookie(c.Writer, &http.Cookie{ - Name: webuiSessionCookie, - Value: sid, - Path: "/", - MaxAge: int(a.ttl.Seconds()), - HttpOnly: true, // JS can never read it (XSS-proof storage) - SameSite: http.SameSiteStrictMode, // cross-site requests never carry it (CSRF) - Secure: c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https", - }) - c.JSON(http.StatusOK, gin.H{ - "ok": true, - "session": sid, - "expires_in_minutes": int(a.ttl.Minutes()), - "user": gin.H{"username": sess.Username, "role": sess.Role, "is_admin": sess.Role == repo.RoleAdmin}, - }) -} - -// logout invalidates the presented session and clears the session cookie. -func (a *webuiAuthenticator) logout(c *gin.Context) { - if tok := a.extractToken(c); tok != "" { - a.mu.Lock() - delete(a.sessions, tok) - a.mu.Unlock() - } - http.SetCookie(c.Writer, &http.Cookie{ - Name: webuiSessionCookie, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - }) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -func (a *webuiAuthenticator) throttleAbort(c *gin.Context) { - c.Header("Retry-After", strconv.Itoa(int(webuiFailWindow.Seconds()))) - c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ - "detail": "too many failed attempts, retry after the cooldown", - }) -} - -// extractToken finds the presented credential: X-WebUI-Token header, Bearer -// token, or the session cookie (browser navigation requests can only carry -// the cookie, which is what enables the server-side page gate). -func (a *webuiAuthenticator) extractToken(c *gin.Context) string { - got := c.GetHeader("X-WebUI-Token") - if got == "" { - if b := c.GetHeader("Authorization"); len(b) > 7 && b[:7] == "Bearer " { - got = b[7:] - } - } - if got == "" { - got, _ = c.Cookie(webuiSessionCookie) - } - return got -} - -// authenticate resolves a credential (session id or master token) to an -// identity. The master token is compared in constant time and maps to an admin -// identity so scripts can call /api/* without a user session. -func (a *webuiAuthenticator) authenticate(cred string) (webuiSession, bool) { - if cred == "" { - return webuiSession{}, false - } - if a.masterToken != "" && subtle.ConstantTimeCompare([]byte(cred), []byte(a.masterToken)) == 1 { - return webuiSession{Username: "master-token", Role: repo.RoleAdmin, Expiry: time.Now().Add(a.ttl)}, true - } - a.mu.Lock() - defer a.mu.Unlock() - a.pruneSessionsLocked() - if s, ok := a.sessions[cred]; ok && time.Now().Before(s.Expiry) { - return s, true - } - return webuiSession{}, false -} - -// verifyUser checks a username + password against the payadmin_user store. -func (a *webuiAuthenticator) verifyUser(username, password string) (webuiSession, bool) { - u, err := repo.GetUserByUsername(username) - if err != nil || u == nil { - return webuiSession{}, false - } - if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil { - return webuiSession{}, false - } - return webuiSession{UserID: u.ID, Username: u.Username, Role: u.Role, Expiry: time.Now().Add(a.ttl)}, true -} - -func (a *webuiAuthenticator) newSession(user webuiSession) (string, error) { - b := make([]byte, webuiSessionBytes) - if _, err := rand.Read(b); err != nil { - return "", err - } - sid := hex.EncodeToString(b) - a.mu.Lock() - a.sessions[sid] = user - a.mu.Unlock() - return sid, nil -} - -func (a *webuiAuthenticator) pruneSessionsLocked() { - now := time.Now() - for sid, s := range a.sessions { - if now.After(s.Expiry) { - delete(a.sessions, sid) - } - } -} - -func (a *webuiAuthenticator) allow(ip string) bool { - a.mu.Lock() - defer a.mu.Unlock() - a.rollWindowLocked() - return a.fails[ip] < webuiMaxFails -} - -func (a *webuiAuthenticator) fail(ip string) { - a.mu.Lock() - defer a.mu.Unlock() - a.rollWindowLocked() - a.fails[ip]++ -} - -func (a *webuiAuthenticator) reset(ip string) { - a.mu.Lock() - defer a.mu.Unlock() - delete(a.fails, ip) -} - -func (a *webuiAuthenticator) rollWindowLocked() { - now := time.Now() - if now.Sub(a.windowStart) >= webuiFailWindow { - a.fails = make(map[string]int) - a.windowStart = now - } -} - -// currentUser reads the authed identity set by the auth middleware. -func currentUser(c *gin.Context) (webuiSession, bool) { - v, ok := c.Get(webuiCtxUser) - if !ok { - return webuiSession{}, false - } - s, ok := v.(webuiSession) - return s, ok -} - -// operatorOf returns the authed username for audit logging (empty for master -// token / header-only callers). -func operatorOf(c *gin.Context) string { - if s, ok := currentUser(c); ok && s.Username != "" { - return s.Username - } - return "" -} - -// requireAdmin gates a route group to role=admin. -func requireAdmin() gin.HandlerFunc { - return func(c *gin.Context) { - if s, ok := currentUser(c); ok && s.Role == repo.RoleAdmin { - c.Next() - return - } - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"detail": "admin role required"}) - } -} - -// ── info ──────────────────────────────────────────────────────────── - -func (r *Router) apiInfo(c *gin.Context) { - out := gin.H{"service": "app-pay-admin", "version": webuiVersion, "status": "running"} - if s, ok := currentUser(c); ok { - out["user"] = gin.H{"username": s.Username, "role": s.Role, "is_admin": s.Role == repo.RoleAdmin} - } - c.JSON(http.StatusOK, out) -} - -// ── shared helpers ────────────────────────────────────────────────── - -func pagination(c *gin.Context, def, max int) (limit, offset int) { - limit = def - if l := c.Query("limit"); l != "" { - if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= max { - limit = n - } - } - if o := c.Query("offset"); o != "" { - if n, err := strconv.Atoi(o); err == nil && n >= 0 { - offset = n - } - } - return limit, offset -} - -func timePtr(t *time.Time) any { - if t == nil { - return nil - } - return t.Format(time.RFC3339) -} diff --git a/app-pay-admin/internal/router/webui_test.go b/app-pay-admin/internal/router/webui_test.go deleted file mode 100644 index 2ccefd37..00000000 --- a/app-pay-admin/internal/router/webui_test.go +++ /dev/null @@ -1,341 +0,0 @@ -package router - -// Tests for the pay admin console auth hardening: username+password login, -// session cookie flow, per-IP brute-force throttling, role gating, and the -// CORS / security response headers (mirrors app-task's webui tests). - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - - "app-pay-admin/internal/appops" - "app-pay-admin/internal/config" - "app-pay-admin/internal/db" - "app-pay-admin/internal/model" - "app-pay-admin/internal/repo" - "app-pay-admin/internal/service" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -// setupTestDB swaps the global GORM handle for a fresh in-memory SQLite with -// every model migrated (including the pay_* row mappings — production MySQL -// never migrates them, but the test tables mirror the real schema). -func setupTestDB(t *testing.T) { - t.Helper() - gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - sqlDB, _ := gdb.DB() - sqlDB.SetMaxOpenConns(1) - if err := gdb.AutoMigrate( - &model.PayOrder{}, &model.PayMembership{}, &model.PayMembershipEvent{}, - &model.PayCallbackLog{}, &model.PayProduct{}, &model.PayAdminUser{}, - &model.PayRefundApproval{}, - ); err != nil { - t.Fatalf("migrate: %v", err) - } - db.DB = gdb -} - -// newTestRouter builds a router with the webui enabled and the default admin -// seeded. The audit sink goes to a temp file so grant tests can assert on it. -func newTestRouter(t *testing.T, token string) http.Handler { - t.Helper() - h, _ := newTestRouterFull(t, token) - return h -} - -// newTestRouterFull additionally returns the audit JSONL path for assertions. -func newTestRouterFull(t *testing.T, token string) (http.Handler, string) { - t.Helper() - setupTestDB(t) - if err := repo.EnsureDefaultAdmin(); err != nil { - t.Fatalf("seed admin: %v", err) - } - cfg := &config.Config{} - cfg.WebUI.Enabled = true - cfg.WebUI.Token = token - cfg.Security.CORS.AllowOrigins = []string{"*"} - cfg.Audit.File = filepath.Join(t.TempDir(), "pay-admin-audit.jsonl") - audit := service.NewAudit(cfg.Audit.File) - // Windows cannot delete a file with an open handle: close before TempDir - // cleanup runs. - t.Cleanup(func() { _ = audit.Close() }) - return New(cfg, service.NewGrantService(audit, service.GrantCaps{}), appops.New("", "", 5)), cfg.Audit.File -} - -func doJSON(h http.Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { - req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) - if body != "" { - req.Header.Set("Content-Type", "application/json") - } - for k, v := range headers { - req.Header.Set(k, v) - } - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - return w -} - -// adminHeaders returns a session-cookie header for the default admin account, -// logged in against the given router (each test router has its own session -// store, so the login must happen per instance). -func adminHeaders(t *testing.T, h http.Handler) map[string]string { - t.Helper() - w := doJSON(h, "POST", "/api/login", `{"username":"admin","password":"`+repo.DefaultAdminPass+`"}`, nil) - if w.Code != http.StatusOK { - t.Fatalf("admin login: got %d, body %s", w.Code, w.Body.String()) - } - sid := sessionFromCookie(w) - if sid == "" { - t.Fatal("admin login returned no session cookie") - } - return map[string]string{"Cookie": webuiSessionCookie + "=" + sid} -} - -// sessionFromCookie extracts the session id from a login Set-Cookie header. -func sessionFromCookie(w *httptest.ResponseRecorder) string { - ck := w.Header().Get("Set-Cookie") - prefix := webuiSessionCookie + "=" - i := bytes.Index([]byte(ck), []byte(prefix)) - if i < 0 { - return "" - } - rest := ck[i+len(prefix):] - if j := bytes.IndexByte([]byte(rest), ';'); j >= 0 { - rest = rest[:j] - } - return rest -} - -// ── username + password session flow ───────────────────────────────── - -func TestWebuiLoginSessionFlow(t *testing.T) { - h := newTestRouter(t, "") - - // Wrong password rejected; empty credentials rejected without throttle. - if w := doJSON(h, "POST", "/api/login", `{"username":"admin","password":"wrong"}`, nil); w.Code != http.StatusUnauthorized { - t.Fatalf("login wrong password: got %d, want 401", w.Code) - } - if w := doJSON(h, "POST", "/api/login", `{}`, nil); w.Code != http.StatusUnauthorized { - t.Fatalf("login empty: got %d, want 401", w.Code) - } - - // Correct credentials -> session issued (body + HttpOnly cookie). - w := doJSON(h, "POST", "/api/login", `{"username":"admin","password":"`+repo.DefaultAdminPass+`"}`, nil) - if w.Code != http.StatusOK { - t.Fatalf("login admin: got %d, want 200 (body %s)", w.Code, w.Body.String()) - } - var loginResp struct { - OK bool `json:"ok"` - Session string `json:"session"` - User struct { - Username string `json:"username"` - Role string `json:"role"` - IsAdmin bool `json:"is_admin"` - } `json:"user"` - } - if err := json.Unmarshal(w.Body.Bytes(), &loginResp); err != nil { - t.Fatalf("parse login response: %v", err) - } - if !loginResp.OK || loginResp.Session == "" || loginResp.User.Username != "admin" || !loginResp.User.IsAdmin { - t.Fatalf("login response: %s", w.Body.String()) - } - cookie := w.Header().Get("Set-Cookie") - if !bytes.Contains([]byte(cookie), []byte(webuiSessionCookie+"="+loginResp.Session)) || - !bytes.Contains([]byte(cookie), []byte("HttpOnly")) || - !bytes.Contains([]byte(cookie), []byte("SameSite=Strict")) { - t.Fatalf("login Set-Cookie malformed: %q", cookie) - } - - // Session authorizes the API via both accepted headers AND the cookie. - for name, hh := range map[string]map[string]string{ - "x-webui-token": {"X-WebUI-Token": loginResp.Session}, - "bearer": {"Authorization": "Bearer " + loginResp.Session}, - "cookie": {"Cookie": webuiSessionCookie + "=" + loginResp.Session}, - } { - if w := doJSON(h, "GET", "/api/info", "", hh); w.Code != http.StatusOK { - t.Fatalf("info with %s: got %d, want 200", name, w.Code) - } - } - - // Logout invalidates the session and clears the cookie. - lo := doJSON(h, "POST", "/api/logout", "{}", map[string]string{"Cookie": webuiSessionCookie + "=" + loginResp.Session}) - if lo.Code != http.StatusOK { - t.Fatalf("logout: got %d, want 200", lo.Code) - } - if w := doJSON(h, "GET", "/api/info", "", map[string]string{"X-WebUI-Token": loginResp.Session}); w.Code != http.StatusUnauthorized { - t.Fatalf("info after logout: got %d, want 401", w.Code) - } -} - -// ── master token (API key) login path ──────────────────────────────── - -func TestWebuiLoginMasterToken(t *testing.T) { - h := newTestRouter(t, "master-key") - - w := doJSON(h, "POST", "/api/login", `{"token":"master-key"}`, nil) - if w.Code != http.StatusOK { - t.Fatalf("login master token: got %d, want 200", w.Code) - } - var resp struct { - OK bool `json:"ok"` - Session string `json:"session"` - User struct { - IsAdmin bool `json:"is_admin"` - } `json:"user"` - } - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if !resp.OK || resp.Session == "" || !resp.User.IsAdmin { - t.Fatalf("master-token login response: %s", w.Body.String()) - } - // The master token also authorizes /api/* directly. - if w := doJSON(h, "GET", "/api/info", "", map[string]string{"X-WebUI-Token": "master-key"}); w.Code != http.StatusOK { - t.Fatalf("info with raw master token: got %d, want 200", w.Code) - } -} - -// ── page gate: unauthenticated visitors never receive page HTML ───── - -func TestWebuiPageGate(t *testing.T) { - h := newTestRouter(t, "") - - // Unauthenticated: every page redirects to the login page... - w := doJSON(h, "GET", "/", "", nil) - if w.Code != http.StatusFound || w.Header().Get("Location") != "/login" { - t.Fatalf("unauthenticated /: got %d Location=%q, want 302 /login", w.Code, w.Header().Get("Location")) - } - w = doJSON(h, "GET", "/orders", "", nil) - if w.Code != http.StatusFound || w.Header().Get("Location") != "/login" { - t.Fatalf("unauthenticated /orders: got %d, want 302 /login", w.Code) - } - // ...which serves the standalone login page (no app shell). - w = doJSON(h, "GET", "/login", "", nil) - if w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte("支付后台")) { - t.Fatalf("GET /login: got %d, want 200 login page", w.Code) - } - - // The app shell is NOT reachable via the public static route. - for _, asset := range []string{"/assets/app.css", "/assets/logo.svg", "/assets/login-art.svg"} { - if w := doJSON(h, "GET", asset, "", nil); w.Code != http.StatusOK { - t.Fatalf("%s: got %d, want 200", asset, w.Code) - } - } - if w := doJSON(h, "GET", "/assets/index.html", "", nil); w.Code != http.StatusNotFound { - t.Fatalf("/assets/index.html: got %d, want 404", w.Code) - } - - // Login -> session cookie unlocks the pages; /login bounces back. - ah := adminHeaders(t, h) - if w := doJSON(h, "GET", "/", "", ah); w.Code != http.StatusFound || w.Header().Get("Location") != "/orders" { - t.Fatalf("authenticated /: got %d, want 302 /orders", w.Code) - } - if w := doJSON(h, "GET", "/orders", "", ah); w.Code != http.StatusOK || - !bytes.Contains(w.Body.Bytes(), []byte(`id="app"`)) || !bytes.Contains(w.Body.Bytes(), []byte("订单号")) { - t.Fatalf("authenticated /orders: got %d, want 200 orders page", w.Code) - } - if w := doJSON(h, "GET", "/login", "", ah); w.Code != http.StatusFound || w.Header().Get("Location") != "/" { - t.Fatalf("authenticated /login: got %d Location=%q, want 302 /", w.Code, w.Header().Get("Location")) - } -} - -// ── brute-force throttle ───────────────────────────────────────────── - -func TestWebuiLoginThrottle(t *testing.T) { - h := newTestRouter(t, "") - - for i := 0; i < webuiMaxFails; i++ { - if w := doJSON(h, "POST", "/api/login", `{"username":"admin","password":"nope"}`, nil); w.Code != http.StatusUnauthorized { - t.Fatalf("throttle login %d: got %d, want 401", i, w.Code) - } - } - // Window exhausted: even the CORRECT password is throttled (429). - w := doJSON(h, "POST", "/api/login", `{"username":"admin","password":"`+repo.DefaultAdminPass+`"}`, nil) - if w.Code != http.StatusTooManyRequests { - t.Fatalf("throttled correct login: got %d, want 429", w.Code) - } - if w.Header().Get("Retry-After") == "" { - t.Fatal("429 response missing Retry-After header") - } - // Other /api/* endpoints share the same per-IP budget. - if w := doJSON(h, "GET", "/api/info", "", nil); w.Code != http.StatusTooManyRequests { - t.Fatalf("throttled api access: got %d, want 429", w.Code) - } -} - -// ── CORS + security headers ────────────────────────────────────────── - -func TestSecurityHeaders(t *testing.T) { - setupTestDB(t) - _ = repo.EnsureDefaultAdmin() - cfg := &config.Config{} - cfg.WebUI.Enabled = true - cfg.Security.CORS.AllowOrigins = []string{"http://localhost:3000"} - cfg.Audit.File = filepath.Join(t.TempDir(), "a.jsonl") - h := New(cfg, service.NewGrantService(service.NewAudit(cfg.Audit.File), service.GrantCaps{}), appops.New("", "", 5)) - - // Baseline hardening on every response. - w := doJSON(h, "GET", "/health", "", nil) - for _, want := range [][2]string{ - {"X-Content-Type-Options", "nosniff"}, - {"X-Frame-Options", "DENY"}, - {"Content-Security-Policy", "frame-ancestors 'none'"}, - {"Referrer-Policy", "no-referrer"}, - } { - if got := w.Header().Get(want[0]); got != want[1] { - t.Fatalf("header %s = %q, want %q", want[0], got, want[1]) - } - } - - // CORS allowlist: matching origin reflected, others ignored. - req := httptest.NewRequest("OPTIONS", "/api/info", nil) - req.Header.Set("Origin", "http://localhost:3000") - w2 := httptest.NewRecorder() - h.ServeHTTP(w2, req) - if w2.Code != http.StatusNoContent || w2.Header().Get("Access-Control-Allow-Origin") != "http://localhost:3000" { - t.Fatalf("CORS preflight: got %d %q, want 204 with origin echoed", w2.Code, w2.Header().Get("Access-Control-Allow-Origin")) - } - req2 := httptest.NewRequest("GET", "/api/info", nil) - req2.Header.Set("Origin", "http://evil.example.com") - w3 := httptest.NewRecorder() - h.ServeHTTP(w3, req2) - if w3.Header().Get("Access-Control-Allow-Origin") != "" { - t.Fatal("CORS must not echo a non-allowlisted origin") - } -} - -// ── role gating: viewer cannot grant or manage accounts ───────────── - -func TestRoleGating(t *testing.T) { - h := newTestRouter(t, "") - - // Create a viewer account via the admin session. - ah := adminHeaders(t, h) - if w := doJSON(h, "POST", "/api/users", `{"username":"ops","password":"ops-pass-123","role":"viewer"}`, ah); w.Code != http.StatusOK { - t.Fatalf("create viewer: got %d, body %s", w.Code, w.Body.String()) - } - vw := doJSON(h, "POST", "/api/login", `{"username":"ops","password":"ops-pass-123"}`, nil) - if vw.Code != http.StatusOK { - t.Fatalf("viewer login: got %d", vw.Code) - } - vh := map[string]string{"Cookie": webuiSessionCookie + "=" + sessionFromCookie(vw)} - - // Viewer CAN read pay-domain queries... - if w := doJSON(h, "GET", "/api/orders", "", vh); w.Code != http.StatusOK { - t.Fatalf("viewer list orders: got %d, want 200", w.Code) - } - // ...but CANNOT grant or touch accounts. - if w := doJSON(h, "POST", "/api/grants", `{"uid":1,"duration_days":30,"reason":"x"}`, vh); w.Code != http.StatusForbidden { - t.Fatalf("viewer grant: got %d, want 403", w.Code) - } - if w := doJSON(h, "GET", "/api/users", "", vh); w.Code != http.StatusForbidden { - t.Fatalf("viewer list users: got %d, want 403", w.Code) - } -} diff --git a/app-pay-admin/main.go b/app-pay-admin/main.go deleted file mode 100644 index f131ede8..00000000 --- a/app-pay-admin/main.go +++ /dev/null @@ -1,183 +0,0 @@ -// app-pay-admin entrypoint: load config, init DB (app_pay on app-pay-mysql), -// seed the console admin account, serve the embedded web console, graceful -// shutdown. Read-mostly admin for the pay domain with one controlled write -// (membership grant). Run from project root: go run ./app-pay-admin -package main - -import ( - "context" - "crypto/tls" - _ "embed" // embed default.yaml via //go:embed below - "flag" - "fmt" - "log/slog" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - _ "time/tzdata" // embed IANA tz database so Asia/Shanghai works in distroless - - "app-pay-admin/internal/appops" - "app-pay-admin/internal/certgen" - "app-pay-admin/internal/config" - "app-pay-admin/internal/db" - "app-pay-admin/internal/logger" - "app-pay-admin/internal/repo" - "app-pay-admin/internal/router" - "app-pay-admin/internal/service" -) - -//go:embed default.yaml -var defaultYAML []byte - -func main() { - // Config overlay file resolution (documented in README): - // 1. -config flag (docker: compose passes config.docker.yaml); - // 2. a single positional argument (PowerShell-friendly: `go run . config.dev.yaml`); - // 3. auto-detect config.dev.yaml in the working directory — plain - // `go run .` just works for local dev. The Docker image never - // contains that file (only config.docker.yaml is COPYed), so - // auto-detection cannot leak into the container. - configFile := flag.String("config", "", "overlay config file merged over the embedded default.yaml (e.g. config.dev.yaml)") - flag.Parse() - - overlayPath := *configFile - if overlayPath == "" && flag.NArg() > 0 { - overlayPath = flag.Arg(0) - } - autoDetected := false - if overlayPath == "" { - if _, err := os.Stat("config.dev.yaml"); err == nil { - overlayPath = "config.dev.yaml" - autoDetected = true - } - } - - cfg, err := config.Load(defaultYAML, overlayPath) - if err != nil { - fmt.Fprintf(os.Stderr, "load config: %v\n", err) - os.Exit(1) - } - - // Logging (slog; level/format from log config, debug flag bumps info->debug) - logLevel := cfg.Log.Level - if logLevel == "" { - logLevel = "info" - } - if cfg.App.Debug && logLevel == "info" { - logLevel = "debug" - } - logger.Init(logger.Options{ - Level: logLevel, - Format: cfg.Log.Format, - Output: cfg.Log.Output, - File: logger.FileOptions{ - Path: cfg.Log.File.Path, - MaxSize: cfg.Log.File.MaxSize, - MaxBackups: cfg.Log.File.MaxBackups, - MaxAge: cfg.Log.File.MaxAge, - Compress: cfg.Log.File.Compress, - }, - }) - if overlayPath != "" { - slog.Info("[CONFIG] overlay loaded", "file", overlayPath, "auto_detected", autoDetected) - } - slog.Info("app-pay-admin starting", "port", cfg.Server.Port, "tz", cfg.Timezone) - - // Validate critical config: fail loud instead of silently broken. - if err := config.Validate(cfg); err != nil { - slog.Error("config validation failed", "err", err) - os.Exit(1) - } - - // MySQL (GORM): the app_pay database on the app-pay-mysql instance. - // pay_* schema is Java-owned — db.Migrate only creates payadmin_user. - if err := db.Init(cfg.RDBMS.URL, cfg.RDBMS.MaxOpenConns, cfg.RDBMS.MaxIdleConns, cfg.RDBMS.ConnMaxLifetime, cfg.App.Debug); err != nil { - slog.Error("init mysql failed", "err", err) - os.Exit(1) - } - defer db.Close() - - if err := db.Migrate(); err != nil { - slog.Error("db migrate failed", "err", err) - os.Exit(1) - } - - // Seed the default console admin account on first boot so the console is - // always login-gated (username/password auth, see repo/user.go). - if err := repo.EnsureDefaultAdmin(); err != nil { - slog.Error("seed default console admin failed", "err", err) - os.Exit(1) - } - - // Business timezone: app-pay stores DATETIME wall-clock in Asia/Shanghai - // (pinned JVM timezone). Setting time.Local to the same zone + DSN - // loc=Local keeps DATETIME semantics identical on the Go side. - if loc, err := time.LoadLocation(cfg.Timezone); err == nil { - time.Local = loc - } else { - slog.Warn("invalid timezone, falling back to UTC", "tz", cfg.Timezone, "err", err) - } - - // JSONL audit trail for money-touching operations (mirrors app-pay's - // pay-audit.jsonl; direct-DB writes bypass the Java-side PayAuditLogger). - audit := service.NewAudit(cfg.Audit.File) - defer audit.Close() - grants := service.NewGrantService(audit, service.GrantCaps{ - DailyMaxCount: cfg.Grant.DailyMaxCount, - DailyMaxDays: cfg.Grant.DailyMaxDays, - }) - appopsClient := appops.New(cfg.AppPay.BaseURL, cfg.AppPay.OpsKey, cfg.AppPay.TimeoutSeconds) - - // HTTP server (Gin): embedded admin console + /api/*. - handler := router.New(cfg, grants, appopsClient) - addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) - srv := &http.Server{Addr: addr, Handler: handler} - - // TLS 可选:启用时整站 HTTPS(cookie 的 Secure 标志随 c.Request.TLS 自动置位)。 - // cert/key 成对提供=用指定文件(外部续期后自动重载);双双留空=自动生成自签开发 - // 证书并保存(certgen)。Rotator 后台巡检:自动模式余量 <30 天重生成并热切换, - // 显式模式文件变化重载——均无需重启。 - scheme := "http" - serve := func() error { return srv.ListenAndServe() } - if cfg.Server.TLS.Enabled { - rot, err := certgen.NewRotator(cfg.Server.TLS.Cert, cfg.Server.TLS.Key, cfg.Server.TLS.CertDir) - if err != nil { - slog.Error("tls certificate setup failed", "err", err) - os.Exit(1) - } - autoGenerated := cfg.Server.TLS.Cert == "" && cfg.Server.TLS.Key == "" - slog.Info("[TLS] certificate ready", "cert", rot.CertPath(), "auto_generated", autoGenerated, - "auto_rotate", autoGenerated) - stopRotator := make(chan struct{}) - defer close(stopRotator) - rot.Start(stopRotator) - srv.TLSConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - GetCertificate: rot.GetCertificate, - } - scheme = "https" - serve = func() error { return srv.ListenAndServeTLS("", "") } - } - - go func() { - slog.Info("app-pay-admin listening", "addr", addr, "scheme", scheme) - if err := serve(); err != nil && err != http.ErrServerClosed { - slog.Error("server stopped", "err", err) - os.Exit(1) - } - }() - - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - slog.Info("shutting down") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := srv.Shutdown(ctx); err != nil { - slog.Error("shutdown error", "err", err) - } - slog.Info("app-pay-admin stopped") -} diff --git a/app-pay-admin/scripts/gen-dev-cert.ps1 b/app-pay-admin/scripts/gen-dev-cert.ps1 deleted file mode 100644 index 9ca6adbb..00000000 --- a/app-pay-admin/scripts/gen-dev-cert.ps1 +++ /dev/null @@ -1,57 +0,0 @@ -# ============================================================================== -# app-pay-admin 本地开发 TLS 证书生成(PowerShell 版,与 gen-dev-cert.sh 等价): -# 自建 mini-CA + 服务端叶子证书,输出到 app-pay-admin/certs/(gitignored)。 -# ca.crt / admin.crt / admin.key —— 用法见 .sh 版头部注释。 -# SAN 覆盖:localhost / 127.0.0.1 / ::1 / app-pay-admin。 -# 生产不要用本脚本:挂正式 CA 签发证书。 -# ============================================================================== -$ErrorActionPreference = "Stop" - -$Dir = Join-Path $PSScriptRoot "..\certs" -New-Item -ItemType Directory -Force -Path $Dir | Out-Null -$Dir = (Resolve-Path $Dir).Path - -$DaysCa = 3650 -$DaysServer = 825 - -# openssl 优先取 PATH,其次常见 Git 安装位置(Windows 无内置 openssl) -$openssl = (Get-Command openssl -ErrorAction SilentlyContinue).Source -if (-not $openssl) { - foreach ($cand in @( - "$env:ProgramFiles\Git\usr\bin\openssl.exe", - "${env:ProgramFiles(x86)}\Git\usr\bin\openssl.exe", - "$env:LOCALAPPDATA\Programs\Git\usr\bin\openssl.exe")) { - if (Test-Path $cand) { $openssl = $cand; break } - } -} -if (-not $openssl) { throw "未找到 openssl.exe(请安装 Git for Windows 或加入 PATH)" } -Write-Host "==> openssl: $openssl" -Write-Host "==> 输出目录: $Dir" - -& $openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$Dir\ca.key" 2>$null -& $openssl req -x509 -new -key "$Dir\ca.key" -sha256 -days $DaysCa ` - -subj "/CN=app-pay-admin-dev-ca/O=MindBase" ` - -addext "basicConstraints=critical,CA:TRUE" ` - -addext "keyUsage=critical,keyCertSign,cRLSign" ` - -out "$Dir\ca.crt" - -& $openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$Dir\admin.key" 2>$null -& $openssl req -new -key "$Dir\admin.key" -subj "/CN=app-pay-admin/O=MindBase" -out "$Dir\admin.csr" - -$SanFile = "$env:TEMP\apa-san.cnf" -@" -basicConstraints=critical,CA:FALSE -keyUsage=critical,digitalSignature,keyEncipherment -extendedKeyUsage=serverAuth -subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1,DNS:app-pay-admin -"@ | Set-Content -Encoding ASCII $SanFile - -& $openssl x509 -req -in "$Dir\admin.csr" -CA "$Dir\ca.crt" -CAkey "$Dir\ca.key" ` - -CAcreateserial -days $DaysServer -sha256 ` - -extfile $SanFile -out "$Dir\admin.crt" 2>$null -Remove-Item "$Dir\admin.csr" -ErrorAction SilentlyContinue -Remove-Item "$Dir\ca.srl" -ErrorAction SilentlyContinue -Remove-Item $SanFile -ErrorAction SilentlyContinue - -Write-Host "==> 生成完成: ca.crt / admin.crt / admin.key" -Write-Host "==> 启用方式: PAYADMIN__SERVER__TLS__ENABLED=true PAYADMIN__SERVER__TLS__CERT=$Dir\admin.crt PAYADMIN__SERVER__TLS__KEY=$Dir\admin.key" diff --git a/app-pay-admin/scripts/gen-dev-cert.sh b/app-pay-admin/scripts/gen-dev-cert.sh deleted file mode 100644 index 8ecd9e30..00000000 --- a/app-pay-admin/scripts/gen-dev-cert.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================== -# app-pay-admin 本地开发 TLS 证书生成:自建 mini-CA + 服务端叶子证书(PEM)。 -# 与 app-pay/scripts/gen-dev-cert.* 同一套做法,输出到 app-pay-admin/certs/ -# (gitignored): -# ca.crt 自建 CA(浏览器可选信任;信任后无证书告警) -# admin.crt 服务端证书(PAYADMIN__SERVER__TLS__CERT) -# admin.key 服务端私钥(PAYADMIN__SERVER__TLS__KEY,PKCS#8 PEM) -# -# SAN 覆盖:localhost / 127.0.0.1 / ::1 / app-pay-admin。 -# 重复执行会覆盖旧证书。生产不要用本脚本:挂正式 CA 签发证书。 -# ============================================================================== -set -euo pipefail - -export MSYS_NO_PATHCONV=1 -export MSYS2_ARG_CONV_EXCL="*" - -DIR="$(cd "$(dirname "$0")/.." && pwd)/certs" -DAYS_CA=3650 -DAYS_SERVER=825 - -mkdir -p "$DIR" -cd "$DIR" - -echo "==> 输出目录: $DIR" -openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ca.key 2>/dev/null -openssl req -x509 -new -key ca.key -sha256 -days "$DAYS_CA" \ - -subj "/CN=app-pay-admin-dev-ca/O=MindBase" \ - -addext "basicConstraints=critical,CA:TRUE" \ - -addext "keyUsage=critical,keyCertSign,cRLSign" \ - -out ca.crt - -openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out admin.key 2>/dev/null -openssl req -new -key admin.key \ - -subj "/CN=app-pay-admin/O=MindBase" -out admin.csr -# extfile 写临时文件而非进程替换 /dev/fd:mingw64 原生 openssl.exe 读不了它 -printf '%s\n' \ - "basicConstraints=critical,CA:FALSE" \ - "keyUsage=critical,digitalSignature,keyEncipherment" \ - "extendedKeyUsage=serverAuth" \ - "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1,DNS:app-pay-admin" > ext.cnf -openssl x509 -req -in admin.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ - -days "$DAYS_SERVER" -sha256 -extfile ext.cnf -out admin.crt 2>/dev/null -rm -f admin.csr ca.srl ext.cnf - -echo "==> 生成完成: ca.crt / admin.crt / admin.key" -echo "==> 启用方式: PAYADMIN__SERVER__TLS__ENABLED=true \ -PAYADMIN__SERVER__TLS__CERT=<路径>/admin.crt PAYADMIN__SERVER__TLS__KEY=<路径>/admin.key" diff --git a/app-pay-admin/web/assets/app.css b/app-pay-admin/web/assets/app.css deleted file mode 100644 index 1f5e3008..00000000 --- a/app-pay-admin/web/assets/app.css +++ /dev/null @@ -1,306 +0,0 @@ -/* app-pay-admin 控制台样式:服务端渲染(html/template),无前端框架。 - 结构参照 MinIO 控制台(顶栏 + 水平 Tab 导航),信息密度与控件规范参照 - 阿里云控制台(白底高密度表格 / 筛选条 / 状态点 / 描述列表)。 - 刻意克制:无渐变、无玻璃拟态、无大圆角、无动效堆砌。 */ - -:root { - /* 主操作色 = 石墨黑(Apple 式中性 chrome):按钮/激活 Tab/焦点态一律无彩色, - 蓝色仅存在于品牌 logo 与表格链接(--link, Google 式深蓝),避免"AI 蓝"。 */ - --primary: #1f2329; - --primary-hover: #101317; - --primary-bg: #f0f1f3; - --link: #0b57d0; - --text-1: #1d2129; - --text-2: #4e5969; - --text-3: #86909c; - --border: #e5e6eb; - --divider: #f0f1f3; - --fill: #f7f8fa; - --page-bg: #f5f5f7; - --green: #0fa964; - --red: #f54a45; - --orange: #ff8d1a; - --purple: #722ed1; - --gray-dot: #a9aeb8; - --mono: Consolas, "SF Mono", Menlo, monospace; -} -* { box-sizing: border-box; } -html, body { margin: 0; padding: 0; } -body { - font-family: -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; - font-size: 13px; - color: var(--text-1); - background: var(--page-bg); -} -a { color: var(--link); text-decoration: none; } -h1, h2, p, dl { margin: 0; } -.mono { font-family: var(--mono); font-size: 12.5px; } -.strong { font-weight: 600; } -.dim { color: var(--text-3); } -.lnk { color: var(--link); } -.lnk:hover { text-decoration: underline; } -.err-text { color: #cb2634; } - -/* ── 顶栏 ─────────────────────────────────────── */ -.topbar { - height: 48px; background: #fff; border-bottom: 1px solid var(--border); - display: flex; align-items: center; justify-content: space-between; - padding: 0 20px; -} -.brand { display: flex; align-items: center; gap: 9px; } -.logo-img { width: 26px; height: 26px; display: block; } -.brand-name { font-size: 15px; font-weight: 600; } -.brand-tag { - font-family: var(--mono); font-size: 11px; color: var(--text-3); - background: var(--fill); border: 1px solid var(--border); - border-radius: 3px; padding: 1px 6px; -} -.top-actions { display: flex; align-items: center; gap: 12px; } -.ver { font-family: var(--mono); font-size: 11px; color: var(--text-3); } -.sep { width: 1px; height: 16px; background: var(--border); } -.user { font-size: 12.5px; color: var(--text-2); display: inline-flex; align-items: center; gap: 6px; } -.user .role { - font-style: normal; font-size: 11px; color: var(--text-3); - background: var(--fill); border: 1px solid var(--border); border-radius: 3px; padding: 0 5px; -} -form.inline { display: inline; margin: 0; } - -/* ── Tab 导航(MinIO 式) ─────────────────────── */ -.tabs { background: #fff; border-bottom: 1px solid var(--border); } -.tabs-inner { - max-width: 1440px; margin: 0 auto; padding: 0 20px; - display: flex; gap: 4px; -} -.tabs a { - position: relative; display: inline-block; padding: 0 16px; - line-height: 40px; font-size: 13.5px; color: var(--text-2); -} -.tabs a:hover { color: var(--text-1); } -.tabs a.active { color: var(--primary); font-weight: 500; } -.tabs a.active::after { - content: ""; position: absolute; left: 12px; right: 12px; bottom: 0; - height: 2px; background: var(--primary); border-radius: 2px 2px 0 0; -} - -/* ── 页面骨架 ─────────────────────────────────── */ -.page { max-width: 1440px; margin: 0 auto; padding: 20px 20px 40px; width: 100%; } -.page-head { margin-bottom: 14px; } -.page-head h1 { font-size: 18px; font-weight: 600; } -.page-head p { font-size: 12.5px; color: var(--text-3); margin-top: 4px; } - -.flash { - /* 顶部居中浮动消息条(toast 式):成功/警告自动淡出,错误常驻 */ - position: fixed; top: 14px; left: 50%; transform: translateX(-50%); - z-index: 100; display: flex; align-items: center; gap: 8px; - background: #fff; border: 1px solid var(--border); border-radius: 6px; - padding: 10px 16px; font-size: 13px; color: var(--text-1); - box-shadow: 0 6px 20px rgba(29, 33, 41, 0.14); - max-width: 70vw; - animation: flash-out 0.4s ease 4s forwards; -} -.flash::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--gray-dot); flex: none; } -.flash-ok::before { background: var(--green); } -.flash-warn::before { background: var(--orange); } -.flash-err { border-color: #f3b9b6; animation: none; } -.flash-err::before { background: var(--red); } -@keyframes flash-out { to { opacity: 0; visibility: hidden; } } -.notice { - background: #fff7e8; color: #d25f00; - padding: 9px 14px; border-radius: 4px; font-size: 13px; -} - -/* ── 筛选条(阿里云列表页式) ─────────────────── */ -.filter-bar { - background: #fff; border: 1px solid var(--border); border-radius: 6px; - padding: 14px 16px 15px; margin-bottom: 14px; - display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: flex-end; -} -.filter-bar label { - display: flex; flex-direction: column; gap: 4px; - font-size: 12px; color: var(--text-3); -} -.filter-bar input, .filter-bar select { - height: 30px; padding: 4px 9px; font-size: 13px; color: var(--text-1); - border: 1px solid var(--border); border-radius: 4px; background: #fff; - width: 170px; font-family: inherit; -} -.filter-bar input.mono { font-family: var(--mono); font-size: 12.5px; } -.filter-bar .w-xs { width: 128px; } -.filter-bar input:focus, .filter-bar select:focus, -.field input:focus, .field select:focus, .field textarea:focus, -.row-form input:focus { - outline: none; border-color: #6b7280; - box-shadow: 0 0 0 2px rgba(31, 35, 41, 0.10); -} - -/* ── 面板 / 表格 ──────────────────────────────── */ -.panel { - background: #fff; border: 1px solid var(--border); border-radius: 6px; - margin-bottom: 14px; -} -/* 表格圆角(面板不再 overflow:hidden,避免裁掉下拉浮层) */ -.panel > .tbl { border-collapse: separate; border-spacing: 0; } -.panel > .tbl th:first-child { border-top-left-radius: 6px; } -.panel > .tbl th:last-child { border-top-right-radius: 6px; } -.panel > .tbl tr:last-child td:first-child { border-bottom-left-radius: 6px; } -.panel > .tbl tr:last-child td:last-child { border-bottom-right-radius: 6px; } -.panel-body { padding: 14px 16px 16px; } -.panel-title { font-size: 14px; font-weight: 600; margin: 0 0 12px; } -.panel-head { - display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; border-bottom: 1px solid var(--divider); -} -.panel-head .panel-title { margin: 0; } -.panel-note { - font-size: 12px; color: var(--text-3); - padding: 10px 16px; border-top: 1px solid var(--divider); -} -.grid-2 { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 14px; align-items: start; } - -.tbl { width: 100%; border-collapse: collapse; } -.tbl th { - text-align: left; font-size: 12px; font-weight: 500; color: var(--text-3); - background: #fafafa; padding: 8px 14px; white-space: nowrap; -} -.tbl td { - padding: 10px 14px; border-top: 1px solid var(--divider); - vertical-align: top; font-size: 13px; word-break: break-word; -} -.tbl tbody tr:hover td { background: var(--fill); } -.tbl .num { text-align: right; } -.tbl .empty { text-align: center; color: var(--text-3); padding: 32px 0; } -.cell-sub { font-size: 11.5px; color: var(--text-3); margin-top: 2px; } -.payload { - max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - color: var(--text-2); -} -.reason { max-width: 260px; } -.sec-title { font-size: 14px; font-weight: 600; margin: 16px 0 10px; } -.detail-top { margin-bottom: 12px; font-size: 13px; } - -/* ── 状态点(阿里云式:点 + 文本) ─────────────── */ -.st { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-2); white-space: nowrap; } -.st i { width: 6px; height: 6px; border-radius: 50%; background: var(--gray-dot); flex: none; } -.st-green i { background: var(--green); } -.st-blue i { background: #4a7dc9; } -.st-red i { background: var(--red); } -.st-orange i { background: var(--orange); } -.st-purple i { background: var(--purple); } -.st-gray i { background: var(--gray-dot); } - -/* ── 分页 ─────────────────────────────────────── */ -.pager { - display: flex; align-items: center; justify-content: flex-end; gap: 10px; - padding: 10px 14px; border-top: 1px solid var(--divider); - font-size: 12.5px; color: var(--text-3); -} - -/* ── 按钮 ─────────────────────────────────────── */ -.btn { - display: inline-flex; align-items: center; justify-content: center; - height: 30px; padding: 0 14px; font-size: 13px; font-family: inherit; - color: var(--text-1); background: #fff; - border: 1px solid var(--border); border-radius: 4px; cursor: pointer; - white-space: nowrap; -} -.btn:hover { color: var(--text-1); border-color: #b9bec7; background: var(--fill); } -.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; } -.btn.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } -.btn.ghost { border-color: transparent; background: none; color: var(--text-2); } -.btn.ghost:hover { background: var(--fill); color: var(--text-1); } -.btn.danger { color: #cb2634; border-color: #f3b9b6; } -.btn.danger:hover { background: #ffece8; border-color: var(--red); color: #cb2634; } -.btn.sm { height: 26px; padding: 0 10px; font-size: 12px; } -.btn.lg { height: 38px; font-size: 14px; } -.btn.block { width: 100%; } -.btn.disabled { color: var(--text-3); background: var(--fill); border-color: var(--divider); cursor: default; } -a.btn.disabled:hover { color: var(--text-3); } - -/* ── 描述列表(详情页) ───────────────────────── */ -.kv { - display: grid; grid-template-columns: 130px minmax(0, 1fr); column-gap: 14px; - padding: 6px 16px 10px; -} -.kv dt { font-size: 12.5px; color: var(--text-3); padding-top: 8px; } -.kv dd { margin: 0; font-size: 13px; padding: 7px 0; border-bottom: 1px solid var(--divider); } -.kv dt:nth-last-of-type(1), .kv dd:last-of-type { border-bottom: none; } -.kv .strong { font-size: 13px; } - -/* ── 表单(补偿开通 / 账户) ──────────────────── */ -.form { padding: 0; } -.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; } -.field > span { font-size: 12.5px; color: var(--text-2); } -.field input, .field select, .field textarea { - width: 100%; height: 30px; padding: 4px 10px; font-size: 13px; font-family: inherit; - color: var(--text-1); border: 1px solid var(--border); border-radius: 4px; background: #fff; -} -.field textarea { height: auto; padding: 7px 10px; resize: vertical; } -.form-actions { display: flex; justify-content: flex-end; margin-top: 4px; } -.form-card { - position: absolute; right: 0; top: calc(100% + 8px); z-index: 20; - width: 300px; background: #fff; border: 1px solid var(--border); border-radius: 6px; - box-shadow: 0 4px 16px rgba(29, 33, 41, 0.14); padding: 16px; -} - -/* 账户页行内操作 */ -.new-user { position: relative; } -.new-user summary { list-style: none; } -.new-user summary::-webkit-details-marker { display: none; } -.op-col { width: 1%; } -.op-cell { white-space: nowrap; } -.row-form { display: inline-flex; align-items: center; gap: 6px; margin: 0 8px 4px 0; } -.row-form input { - height: 26px; width: 150px; padding: 2px 8px; font-size: 12px; - border: 1px solid var(--border); border-radius: 4px; font-family: inherit; -} - -/* ── 登录页(MinIO 式分栏:左品牌侧栏 + 右表单) ────── */ -.login-body { background: #fff; } -.login-split { display: flex; min-height: 100vh; } -.login-side { - width: 44%; min-width: 380px; background: #0d2f6e; color: #fff; - display: flex; flex-direction: column; - padding: 44px 48px 30px; -} -.side-logo { width: 46px; height: 46px; display: block; } -.side-title { font-size: 22px; font-weight: 600; margin: 18px 0 6px; } -.side-sub { - font-size: 13px; color: rgba(255, 255, 255, 0.65); - margin: 0 0 10px; letter-spacing: 0.5px; -} -.side-art { width: 100%; max-width: 460px; margin: auto 0; } -.side-foot { - font-size: 12px; color: rgba(255, 255, 255, 0.45); - font-family: var(--mono); margin: 12px 0 0; -} -.login-main { - flex: 1; display: flex; align-items: center; justify-content: center; - padding: 40px 32px; -} -.login-box { width: 340px; } -.login-brand { - display: flex; align-items: center; gap: 8px; - font-size: 14px; font-weight: 600; color: var(--text-1); - margin-bottom: 28px; -} -.login-brand img { width: 24px; height: 24px; display: block; } -.login-box h2 { font-size: 22px; font-weight: 600; margin: 0 0 6px; } -.login-box .login-sub { font-size: 13px; color: var(--text-3); margin: 0 0 24px; } -.login-box .field { margin-bottom: 16px; } -.login-box .field > span { font-size: 12.5px; color: var(--text-2); } -.login-box .field input { height: 38px; font-size: 14px; } -.login-box .btn.lg { margin-top: 10px; height: 40px; font-size: 14.5px; } -.login-hint { - margin: 18px 0 0; font-size: 12px; color: var(--text-3); line-height: 1.7; - padding-top: 14px; border-top: 1px solid var(--divider); -} - -@media (max-width: 880px) { - .login-side { display: none; } -} - -/* ── 响应式 ───────────────────────────────────── */ -@media (max-width: 900px) { - .grid-2 { grid-template-columns: 1fr; } - .filter-bar input, .filter-bar select { width: 140px; } -} diff --git a/app-pay-admin/web/assets/login-art.svg b/app-pay-admin/web/assets/login-art.svg deleted file mode 100644 index aeb3e8bd..00000000 --- a/app-pay-admin/web/assets/login-art.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app-pay-admin/web/assets/logo.svg b/app-pay-admin/web/assets/logo.svg deleted file mode 100644 index 0252e5b0..00000000 --- a/app-pay-admin/web/assets/logo.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/app-pay-admin/web/templates/base.tmpl b/app-pay-admin/web/templates/base.tmpl deleted file mode 100644 index 266752ab..00000000 --- a/app-pay-admin/web/templates/base.tmpl +++ /dev/null @@ -1,48 +0,0 @@ -{{define "content"}}{{end}} - - - - -{{.PageTitle}} · MindBase 支付后台 - - - - -
-
-
- - MindBase 支付后台 - app-pay-admin -
-
- {{.Version}} - - {{.User.Username}}{{if .User.IsAdmin}}admin{{else}}viewer{{end}} -
-
-
- - - -
-
-

{{.PageTitle}}

- {{if .PageDesc}}

{{.PageDesc}}

{{end}} -
- {{with .Flash}}{{end}} - {{block "content" .}}{{end}} -
-
- - diff --git a/app-pay-admin/web/templates/events.tmpl b/app-pay-admin/web/templates/events.tmpl deleted file mode 100644 index bb26ae14..00000000 --- a/app-pay-admin/web/templates/events.tmpl +++ /dev/null @@ -1,37 +0,0 @@ -{{define "content"}} -
- - - - 重置 -
- -
- - - - {{range .Items}} - - - - - - - - - - {{else}} - - {{end}} - -
时间uid类型天数变更前 → 变更后关联订单原因
{{.CreatedAt}}{{.UID}}{{.TypeText}}{{.Days}}{{.ExpireBefore}} → {{.ExpireAfter}}{{if .OrderNo}}{{.OrderNo}}{{else}}-{{end}}{{if .Reason}}{{.Reason}}{{else}}-{{end}}
暂无权益变更记录
-
- 共 {{.Total}} 条 · 第 {{.Page}} 页 - {{if .HasPrev}}上一页{{else}}上一页{{end}} - {{if .HasNext}}下一页{{else}}下一页{{end}} -
-
-{{end}} diff --git a/app-pay-admin/web/templates/grants.tmpl b/app-pay-admin/web/templates/grants.tmpl deleted file mode 100644 index 4aac53f8..00000000 --- a/app-pay-admin/web/templates/grants.tmpl +++ /dev/null @@ -1,58 +0,0 @@ -{{define "content"}} -
-
-
-

运营补偿开通 / 延期

- {{if .User.IsAdmin}} -
- - - - -
- -
-
- {{else}} -
当前账户为 viewer 角色,仅可查看;补偿开通需要 admin 角色登录。
- {{end}} -
-
- -
-

最近的 ADMIN_GRANT 记录

- - - - {{range .Recent}} - - - - - - - - {{else}} - - {{end}} - -
时间uid天数变更前 → 变更后原因
{{.CreatedAt}}{{.UID}}{{.Days}}{{.ExpireBefore}} → {{.ExpireAfter}}{{if .Reason}}{{.Reason}}{{else}}-{{end}}
暂无补偿开通记录
-
-
-{{end}} diff --git a/app-pay-admin/web/templates/login.tmpl b/app-pay-admin/web/templates/login.tmpl deleted file mode 100644 index 83706e17..00000000 --- a/app-pay-admin/web/templates/login.tmpl +++ /dev/null @@ -1,47 +0,0 @@ - - - - - -登录 · MindBase 支付后台 - - - - - - - diff --git a/app-pay-admin/web/templates/member_events.tmpl b/app-pay-admin/web/templates/member_events.tmpl deleted file mode 100644 index 2285445c..00000000 --- a/app-pay-admin/web/templates/member_events.tmpl +++ /dev/null @@ -1,28 +0,0 @@ -{{define "content"}} - -
- - - - {{range .Items}} - - - - - - - - - - {{else}} - - {{end}} - -
时间类型天数变更前变更后关联订单原因
{{.CreatedAt}}{{.TypeText}}{{.Days}}{{.ExpireBefore}}{{.ExpireAfter}}{{if .OrderNo}}{{.OrderNo}}{{else}}-{{end}}{{if .Reason}}{{.Reason}}{{else}}-{{end}}
该会员暂无权益变更记录
-
- 共 {{.Total}} 条 · 第 {{.Page}} 页 - {{if .HasPrev}}上一页{{else}}上一页{{end}} - {{if .HasNext}}下一页{{else}}下一页{{end}} -
-
-{{end}} diff --git a/app-pay-admin/web/templates/memberships.tmpl b/app-pay-admin/web/templates/memberships.tmpl deleted file mode 100644 index 43735ed1..00000000 --- a/app-pay-admin/web/templates/memberships.tmpl +++ /dev/null @@ -1,48 +0,0 @@ -{{define "content"}} -
- - - 重置 -
- -{{if .Matched}} -
-
-
uid
{{.Matched.UID}}
-
档位
{{.Matched.Tier}}
-
状态
{{if .Matched.Active}}生效中{{else}}已过期{{end}}
-
到期时间
{{.Matched.ExpireAt}}
-
自动续费
{{if .Matched.AutoRenew}}是{{else}}否{{end}}
-
最近订单
{{if .Matched.LastOrderNo}}{{.Matched.LastOrderNo}}{{else}}-{{end}}
-
-
-{{else if .Searched}} -
该 uid 无会员记录
-{{end}} - -
- - - - {{range .Items}} - - - - - - - - - - {{else}} - - {{end}} - -
uid档位状态到期时间最近订单更新时间事件
{{.UID}}{{.Tier}}{{if .Active}}生效中{{else}}已过期{{end}}{{.ExpireAt}}{{if .LastOrderNo}}{{.LastOrderNo}}{{else}}-{{end}}{{.UpdatedAt}}事件流水
暂无会员
-
- 共 {{.Total}} 条 · 第 {{.Page}} 页 - {{if .HasPrev}}上一页{{else}}上一页{{end}} - {{if .HasNext}}下一页{{else}}下一页{{end}} -
-
-{{end}} diff --git a/app-pay-admin/web/templates/order_detail.tmpl b/app-pay-admin/web/templates/order_detail.tmpl deleted file mode 100644 index d1562520..00000000 --- a/app-pay-admin/web/templates/order_detail.tmpl +++ /dev/null @@ -1,42 +0,0 @@ -{{define "content"}} - - -
-
-
订单号
{{.Order.OrderNo}}
-
状态 / 版本
{{.Order.StatusText}} v{{.Order.Version}}
-
uid
{{.Order.UID}}
-
商品
{{.Order.ProductTitle}}({{.Order.SkuCode}},{{.Order.DurationDays}} 天)
-
金额
{{.Order.Amount}}
-
渠道
{{.Order.Channel}}{{if .Order.ChannelTradeNo}} · {{.Order.ChannelTradeNo}}{{end}}
-
幂等键
{{if .Order.IdempotencyKey}}{{.Order.IdempotencyKey}}{{else}}-{{end}}
-
失败原因
{{if .Order.FailReason}}{{.Order.FailReason}}{{else}}-{{end}}
-
创建时间
{{.Order.CreatedAt}}
-
支付时间
{{.Order.PaidAt}}
-
关闭时间
{{.Order.ClosedAt}}
-
过期时间
{{.Order.ExpiresAt}}
-
更新时间
{{.Order.UpdatedAt}}
-
-
- -

渠道回调流水({{len .Callbacks}})

-
- - - - {{range .Callbacks}} - - - - - - - - - {{else}} - - {{end}} - -
时间渠道验签处理消息报文(脱敏)
{{.CreatedAt}}{{.Channel}}{{.SignatureResult}}{{.HandleResult}}{{if .Message}}{{.Message}}{{else}}-{{end}}{{if .Payload}}{{.Payload}}{{else}}-{{end}}
无回调记录
-
-{{end}} diff --git a/app-pay-admin/web/templates/orders.tmpl b/app-pay-admin/web/templates/orders.tmpl deleted file mode 100644 index 7efb7ab1..00000000 --- a/app-pay-admin/web/templates/orders.tmpl +++ /dev/null @@ -1,49 +0,0 @@ -{{define "content"}} -
- - - - - - - - - - 重置 -
- -
- - - - - - {{range .Items}} - - - - - - - - - - - {{else}} - - {{end}} - -
订单号uid商品金额渠道状态支付时间创建时间
{{.OrderNo}}{{.UID}}{{.ProductTitle}}
{{.SkuCode}}
{{.Amount}}{{.Channel}}{{.StatusText}}{{.PaidAt}}{{.CreatedAt}}
暂无符合条件的订单
-
- 共 {{.Total}} 条 · 第 {{.Page}} 页 - {{if .HasPrev}}上一页{{else}}上一页{{end}} - {{if .HasNext}}下一页{{else}}下一页{{end}} -
-
-{{end}} diff --git a/app-pay-admin/web/templates/products.tmpl b/app-pay-admin/web/templates/products.tmpl deleted file mode 100644 index df8aa5f1..00000000 --- a/app-pay-admin/web/templates/products.tmpl +++ /dev/null @@ -1,23 +0,0 @@ -{{define "content"}} -
- - - - {{range .Items}} - - - - - - - - - - {{else}} - - {{end}} - -
code名称时长价格状态排序更新时间
{{.Code}}{{.Title}}{{.DurationDays}} 天{{.Price}}{{if .Active}}在售{{else}}下架{{end}}{{.Sort}}{{.UpdatedAt}}
暂无 SKU(app-pay 启动时自动播种)
-
价格调整请走 app-pay 运营改库流程(种子价格不覆盖运营侧修改)。
-
-{{end}} diff --git a/app-pay-admin/web/templates/refunds.tmpl b/app-pay-admin/web/templates/refunds.tmpl deleted file mode 100644 index a8e40a7c..00000000 --- a/app-pay-admin/web/templates/refunds.tmpl +++ /dev/null @@ -1,44 +0,0 @@ -{{define "content"}} -
-
-

退款审批单

-
- 状态: - 待处理 - 已通过 - 已驳回 - 全部 - 共 {{.Total}} 条 -
-
- - - - {{range .Items}} - - - - - - - - - {{else}} - - {{end}} - -
时间订单号发起人原因状态操作
{{.CreatedAt}}{{.OrderNo}}{{.Maker}}{{.MakerReason}}{{.Status}} - {{if and (eq .Status "PENDING") $.User.IsAdmin}} -
- - - -
-
- - -
- {{else}}-{{end}} -
无审批单({{if eq .Status "PENDING"}}当前没有待复核退款{{else}}该状态暂无记录{{end}})
-
-{{end}} diff --git a/app-pay-admin/web/templates/users.tmpl b/app-pay-admin/web/templates/users.tmpl deleted file mode 100644 index 3b0ea561..00000000 --- a/app-pay-admin/web/templates/users.tmpl +++ /dev/null @@ -1,58 +0,0 @@ -{{define "content"}} -
-
-

控制台账户

-
- 添加账户 -
- - - -
- -
-
-
-
- - - - - {{range .Items}} - - - - - - - - {{else}} - - {{end}} - -
ID用户名角色创建时间操作
{{.ID}}{{.Username}}{{.Role}}{{.CreatedAt}} -
- - -
- {{if .Deletable}} -
- -
- {{end}} -
暂无账户
-
admin:全部权限(补偿开通 + 账户管理);viewer:只读查询。不能删除自己或最后一个 admin。
-
-{{end}} diff --git a/app-pay-admin/web/web.go b/app-pay-admin/web/web.go deleted file mode 100644 index ee115914..00000000 --- a/app-pay-admin/web/web.go +++ /dev/null @@ -1,18 +0,0 @@ -// Package web embeds the admin console: server-rendered html/template pages -// (templates/) + the stylesheet (assets/, no build step, no frontend JS -// framework). The whole service stays a single self-contained binary -// (distroless-friendly). -package web - -import "embed" - -// Templates holds the html/template sources; the router parses base.tmpl with -// each page into a per-page template set at startup. -// -//go:embed all:templates -var Templates embed.FS - -// Assets holds static files served publicly at /assets (stylesheet only). -// -//go:embed all:assets -var Assets embed.FS diff --git a/deploy/docker/41-app-task.yml b/deploy/docker/41-app-task.yml index 5a56bf5e..b42588d9 100644 --- a/deploy/docker/41-app-task.yml +++ b/deploy/docker/41-app-task.yml @@ -22,7 +22,7 @@ services: - APPTASK__LOG__OUTPUT=both - HTTP_PROXY=${APPTASK_HTTP_PROXY:-} - HTTPS_PROXY=${APPTASK_HTTP_PROXY:-} - - NO_PROXY=${APPTASK_NO_PROXY:-localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-pay-admin,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,.local,.svc,.internal} + - NO_PROXY=${APPTASK_NO_PROXY:-localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,.local,.svc,.internal} ports: - "127.0.0.1:${APP_TASK_PORT:-8001}:8001" volumes: diff --git a/deploy/docker/95-app-pay-admin.yml b/deploy/docker/95-app-pay-admin.yml deleted file mode 100644 index 48ef755b..00000000 --- a/deploy/docker/95-app-pay-admin.yml +++ /dev/null @@ -1,34 +0,0 @@ -# app-pay-admin 支付后台管理(可选,Go,仅回环)—— server2 -# docker compose --env-file ../../.env -f 95-app-pay-admin.yml up -d -# 浏览器经 SSH 隧道访问:ssh -L 8003:127.0.0.1:8003 -name: mb-app-pay-admin - -services: - app-pay-admin: - image: atoncooper/mind-base-app-pay-admin:latest - pull_policy: missing - container_name: mind-base-app-pay-admin - env_file: - - ../../.env - command: ["-config", "config.docker.yaml"] - environment: - - PAYADMIN__RDBMS__URL=mysql+aiomysql://${PAY_MYSQL_USER:-app_pay}:${PAY_MYSQL_PASSWORD:-app-pay}@app-pay-mysql:3306/app_pay - - PAYADMIN__APP_PAY__OPS_KEY=${PAY_OPS_KEY:-} - - PAYADMIN__APP_PAY__BASE_URL=http://apisix:9080 - ports: - - "127.0.0.1:${APP_PAY_ADMIN_PORT:-8003}:8003" - volumes: - - app_pay_admin_logs:/app/logs - - ../../app-pay-admin/certs:/app/certs - restart: unless-stopped - networks: - - mind-base-overlay - -volumes: - app_pay_admin_logs: {} - -networks: - mind-base-overlay: - external: true - name: mind-base-overlay - diff --git a/deploy/docker/96-app-admin.yml b/deploy/docker/96-app-admin.yml index 3260c041..9bd5b8be 100644 --- a/deploy/docker/96-app-admin.yml +++ b/deploy/docker/96-app-admin.yml @@ -1,9 +1,11 @@ -# app-admin 分享审核管理后台(UI + Go API,仅回环)—— server2 +# app-admin 统一管理后台(UI + Go API,仅回环)—— server2 # docker compose --env-file ../../.env -f 96-app-admin.yml up -d # 浏览器经 SSH 隧道访问:ssh -L 3001:127.0.0.1:3001 +# 页面:分享审核 / 用户 / 沙箱 / 支付运营 / 账户 / 审计(原 95-app-pay-admin 已并入) # # ⚠️ 首次启动前 app-cloud 必须已跑过一次 Migrate(cloud_shares 的 moderation 列 # 归 app-cloud 所有);共享 bilirag MySQL 由 .env 的 RDBMS__URL 指定。 +# 支付运营直连 app-pay-mysql(25-app-pay-mysql.yml 单独 up),未起时 /api/pay/* 返回 503。 name: mb-app-admin services: @@ -19,11 +21,23 @@ services: # 种子账户(只插缺失;首登强制改密)。生产务必覆盖默认口令。 - APPADMIN__SEED__USERNAME=${APPADMIN__SEED__USERNAME:-admin} - APPADMIN__SEED__PASSWORD=${APPADMIN__SEED__PASSWORD:-app-admin} + # 可信反向代理(overlay 网段):用于登录限流/会话 IP-设备绑定/审计; + # 只有这些 peer 的 X-Forwarded-* 被采信,空 = 永不信任。 + - APPADMIN__SECURITY__TRUSTED_PROXIES=${APPADMIN__SECURITY__TRUSTED_PROXIES:-10.0.0.0/8,172.16.0.0/12} - APPADMIN__LOG__OUTPUT=both # Share-content review preview via app-cloud (Issue #154). The overlay # network resolves app-cloud by service name; token = shared secret. - APPADMIN__CLOUD_REVIEW__BASE_URL=${APPADMIN__CLOUD_REVIEW__BASE_URL:-http://app-cloud:8007} - APPADMIN__CLOUD_REVIEW__TOKEN=${APP_CLOUD_ADMIN_REVIEW_TOKEN:-} + # 支付运营(原 app-pay-admin 能力并入):第二 GORM 连接直连 app_pay 库。 + # 留空 = 支付管理未配置(/api/pay/* 503),不影响分享审核/账户/沙箱。 + - APPADMIN__PAY_RDBMS__URL=${PAY_ADMIN_RDBMS_URL:-mysql+aiomysql://${PAY_MYSQL_USER:-app_pay}:${PAY_MYSQL_PASSWORD:-app-pay}@app-pay-mysql:3306/app_pay} + # 退款复核:经 APISIX pay-ops consumer 调 app-pay internal 端点(不直写库)。 + # 未配置 key 时退款复核整组 404 隐藏;其余支付查询不受影响。 + - APPADMIN__PAY__OPS__BASE_URL=${APPADMIN__PAY__OPS__BASE_URL:-http://apisix:9080} + - APPADMIN__PAY__OPS__KEY=${PAY_OPS_KEY:-} + # 资金审计 JSONL(补偿开通/退款确认哈希链,365 天),落 app_admin_logs 卷 + - APPADMIN__PAY__AUDIT_FILE=/app/logs/pay-audit.jsonl ports: - "127.0.0.1:${APP_ADMIN_PORT:-8016}:8016" volumes: diff --git a/deploy/docker/README.md b/deploy/docker/README.md index 5745d788..370eef18 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -11,7 +11,7 @@ |---|---|---| | **server1**(manager,边缘层) | 00-certs / 10-frontend / 11-etcd / 12-minio / 13-apisix / 14-nginx | TLS 入口、网关、前端、对象存储 | | **server2**(worker,数据+后端) | 20-redis / 21-mongo / 22-neo4j / 23-milvus / 24-app-task-mysql / 25-app-pay-mysql / 30~37(app-go) / 38~40(MCP) / 41-app-task / 42-app-pay / 43-app-ts | 数据层 + 全部业务后端 | -| 可选 | 90-higress / 95-app-pay-admin / 96-app-admin | .env 已指向外部 AI 网关就不用起 higress;pay-admin 用 SSH 隧道访问 8003、admin UI 用 SSH 隧道访问 3001 | +| 可选 | 90-higress / 96-app-admin | .env 已指向外部 AI 网关就不用起 higress;统一管理后台(分享审核 + 支付运营 + 账户 + 审计)用 SSH 隧道访问 3001 | > 主库 mind_base 用外部 MySQL(.env 的 RDBMS__URL),两份文件里都不含主 mysql; > app-task / app-pay 的两个专用小库(app_task / app_pay,表自动建)默认随 server2 起。 diff --git a/deploy/docker/start-backend.sh b/deploy/docker/start-backend.sh index 1dcf2ec4..1720daa8 100644 --- a/deploy/docker/start-backend.sh +++ b/deploy/docker/start-backend.sh @@ -3,7 +3,7 @@ # server2(数据层 + 后端)一键启动:数据层 -> milvus -> Go服务/MCP -> 支付/任务/AI # 前提:../../ 下已有 .env、secrets/、app-pay/certs/、app-board/certs/(从 server1 拷贝) # swarm worker 已加入;overlay 可见(docker network ls | grep mind-base-overlay) -# 可选:90-higress(.env 未指向外部网关时)、95-app-pay-admin、96-app-admin +# 可选:90-higress(.env 未指向外部网关时)、96-app-admin(统一后台,含支付运营) # # 用法:bash start-backend.sh [--pull] # --pull 先从镜像仓库拉取各服务最新镜像再 up(合并代码后更新容器用)。 @@ -92,6 +92,6 @@ up 41-app-task.yml app-task; wait_ready mind-base-app-task 60 up 43-app-ts.yml app-ts; wait_ready mind-base-app-ts 180 echo -echo "✅ server2 后端完成(可选:90-higress / 95-app-pay-admin / 96-app-admin 单独 up)" +echo "✅ server2 后端完成(可选:90-higress / 96-app-admin 单独 up;统一后台含支付运营)" echo " 验证跨机解析:docker exec mind-base-app-ts getent hosts minio" docker ps --format 'table {{.Names}} {{.Status}}' diff --git a/deploy/k8s/30-apps/app-admin.yaml b/deploy/k8s/30-apps/app-admin.yaml index fcf26251..072ef0ed 100644 --- a/deploy/k8s/30-apps/app-admin.yaml +++ b/deploy/k8s/30-apps/app-admin.yaml @@ -1,9 +1,14 @@ -# app-admin — Go share-moderation admin console (port 8016). +# app-admin — unified admin console API (port 8016; Go + Gin). # Cluster-internal only (mirrors compose loopback binding): access with # `kubectl -n mind-base port-forward svc/app-admin 8016:8016`. # Direct-connects the shared bilirag MySQL (same as app-auth/app-cloud): # read-only users/rbac/cloud metadata + the ONLY write is the four moderation # columns on cloud_shares (never is_revoked; never AutoMigrate shared tables). +# Payment ops (merged from the retired app-pay-admin, 2026-11-03): a SECOND +# GORM connection reads app_pay (orders/memberships/events/callbacks/SKU) and +# the only fund write is the membership grant; refund review goes through the +# app-pay internal endpoints (pay-ops key). Unset pay_rdbms.url => /api/pay/* +# returns 503 while the rest of the console keeps working. apiVersion: v1 kind: Service metadata: @@ -71,6 +76,21 @@ spec: name: mindbase-env key: SANDBOX_ADMIN_TOKEN optional: true + # Payment ops (second GORM connection to app_pay). URL falls back to + # the mindbase-defaults value; unset/empty => /api/pay/* 503. + - name: APPADMIN__PAY_RDBMS__URL + valueFrom: + secretKeyRef: { name: mindbase-env, key: APPADMIN__PAY_RDBMS__URL, optional: true } + # Refund review calls app-pay internal endpoints through the APISIX + # pay-ops consumer; empty key hides the whole refund-review group. + - name: APPADMIN__PAY__OPS__BASE_URL + value: "http://apisix:9080" + - name: APPADMIN__PAY__OPS__KEY + valueFrom: + secretKeyRef: { name: mindbase-env, key: PAY_OPS_KEY, optional: true } + # Fund audit JSONL hash chain (grants / refund confirmations, 365d). + - name: APPADMIN__PAY__AUDIT_FILE + value: /app/logs/pay-audit.jsonl - name: APPADMIN__LOG__OUTPUT value: "both" ports: diff --git a/deploy/k8s/30-apps/app-pay-admin.yaml b/deploy/k8s/30-apps/app-pay-admin.yaml deleted file mode 100644 index 4a3a4fb0..00000000 --- a/deploy/k8s/30-apps/app-pay-admin.yaml +++ /dev/null @@ -1,86 +0,0 @@ -# app-pay-admin — Go payment admin console (port 8003). -# Cluster-internal only (mirrors compose loopback binding): access with -# `kubectl -n mind-base port-forward svc/app-pay-admin 8003:8003`. -# TLS auto-generates into /app/certs (emptyDir) when no cert/key is set. -apiVersion: v1 -kind: Service -metadata: - name: app-pay-admin - labels: - app: app-pay-admin -spec: - selector: - app: app-pay-admin - ports: - - name: http - port: 8003 - targetPort: 8003 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: app-pay-admin -spec: - replicas: 1 - revisionHistoryLimit: 3 - selector: - matchLabels: - app: app-pay-admin - template: - metadata: - labels: - app: app-pay-admin - spec: - initContainers: - - name: wait-mysql - image: busybox:1.36 - command: ["sh", "-c", "until nc -z app-pay-mysql 3306; do echo waiting for app-pay-mysql; sleep 3; done"] - containers: - - name: app-pay-admin - image: atoncooper/mind-base-app-pay-admin:latest - imagePullPolicy: Always - args: ["-config", "config.docker.yaml"] - envFrom: - - configMapRef: - name: mindbase-defaults - - secretRef: - name: mindbase-env - env: - # 退款复核工作台:OPS key 经 in-list 引用(envFrom 变量不可被 $(VAR) 引用); - # 留空 = 复核功能在控制台隐藏(与 compose 行为一致) - - name: PAY_OPS_KEY - valueFrom: - secretKeyRef: { name: mindbase-env, key: PAY_OPS_KEY, optional: true } - - name: PAYADMIN__APP_PAY__OPS_KEY - valueFrom: - secretKeyRef: { name: mindbase-env, key: PAY_OPS_KEY, optional: true } - - name: PAYADMIN__APP_PAY__BASE_URL - value: "http://apisix:9080" - ports: - - containerPort: 8003 - volumeMounts: - - name: logs - mountPath: /app/logs - - name: certs - mountPath: /app/certs - startupProbe: - tcpSocket: - port: 8003 - periodSeconds: 5 - failureThreshold: 12 - readinessProbe: - tcpSocket: - port: 8003 - periodSeconds: 10 - livenessProbe: - tcpSocket: - port: 8003 - periodSeconds: 30 - resources: - requests: { cpu: 50m, memory: 128Mi } - limits: { cpu: 250m, memory: 512Mi } - volumes: - - name: logs - emptyDir: {} - - name: certs - emptyDir: {} diff --git a/deploy/k8s/30-apps/app-task.yaml b/deploy/k8s/30-apps/app-task.yaml index deb2e72e..cc8ffa28 100644 --- a/deploy/k8s/30-apps/app-task.yaml +++ b/deploy/k8s/30-apps/app-task.yaml @@ -58,7 +58,7 @@ spec: - name: APPTASK__HTTP_EXECUTOR__GATEWAY_API_KEY value: "$(APISIX_CONSUMER_KEY)" - name: NO_PROXY - value: localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-pay-admin,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,.local,.svc,.internal + value: localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,.local,.svc,.internal # app-pay timeout-delegation callback CA (lazy-loaded; fallback # polling takes over when app-pay is not deployed) - name: APPTASK__HTTP_EXECUTOR__CA_FILE diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index aec1ba38..4e6a22c9 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -3,7 +3,7 @@ 本目录将 `docker-compose.yml` 的完整拓扑(3×MySQL、Redis、Mongo、Milvus+etcd、 MinIO、Neo4j、frontend、APISIX、Higress、nginx、app-ts(AI 域)、app-go 八服务 (auth/cloud/note/bili/quiz/kg/settings/admin)、app-board + 三个 MCP、app-task、 -app-pay/app-pay-admin、app-admin(分享审核后台 + 沙箱管理,:8016 回环)、 +app-pay、app-admin(统一管理后台:分享审核 + 支付运营 + 沙箱 + 账户 + 审计,:8016 回环)、 app-sandbox(沙箱控制层))等价移植为 k8s 清单;OpenSandbox 运行时(lifecycle server / controller / ingress-gateway / warm pool)见 [40-sandbox](40-sandbox/README.md)。 @@ -137,8 +137,7 @@ nginx 等上游…;apisix 是 standalone data_plane 不依赖 etcd,无 initC |------|------| | Higress 控制台 | `kubectl -n mind-base port-forward svc/higress 18081:8001` → http://127.0.0.1:18081 初始化 admin、配 Provider(供应商真实 key 只在这里)/ Consumer `mindbase-backend`(BEARER) / 路由 `/v1`、`/api/v1`、`/api-ws`;**modelMapping 保持空透传** | | 网关 consumer key 回填 | 控制台生成的 key 填入 `.env` 的 `AI_GATEWAY__API_KEY` → 重建 mindbase-env Secret → rollout restart deploy/app-ts deploy/app-cloud deploy/app-bili | -| app-pay-admin | `kubectl -n mind-base port-forward svc/app-pay-admin 8003:8003` → https://127.0.0.1:8003(种子账户 admin / app-pay-admin,首登改密) | -| app-admin | `kubectl -n mind-base port-forward svc/app-admin 8016:8016` → http://127.0.0.1:8016(分享审核后台 + 沙箱管理,种子账户 admin / app-admin,首登强制改密);前端 frontend-admin 本地 `pnpm dev`(/api 代理 8016) | +| app-admin | `kubectl -n mind-base port-forward svc/app-admin 8016:8016` → http://127.0.0.1:8016(统一后台:分享审核 + 支付运营 + 沙箱 + 账户 + 审计,种子账户 admin / app-admin,首登强制改密);前端 frontend-admin 本地 `pnpm dev`(/api 代理 8016)。支付运营读 app-pay-mysql,退款复核需 `.env` 的 `PAY_OPS_KEY` | | app-sandbox | `kubectl -n mind-base port-forward svc/app-sandbox 8017:8017` → `/health`、`/ready`;运行时依赖 40-sandbox 的 OpenSandbox | | 入口地址 | nginx Service 是 LoadBalancer:本地集群直接用分配的地址;云上绑域名 + 正式证书(更新 nginx-tls Secret 后 restart nginx) | @@ -160,7 +159,7 @@ nginx 等上游…;apisix 是 standalone data_plane 不依赖 etcd,无 initC | 沙箱运行时 | compose `--profile sandbox` + 可选 fake runtime | OpenSandbox Helm(40-sandbox);`app-sandbox` 强制 `runtime.type=opensandbox` + warm pool `pool-python` | | 探针语义与 compose healthcheck 一致(mysqladmin ping / mongosh ping / redis-cli ping / etcdctl endpoint health / minio health/live / backend /health); -无 HTTP 健康端点的 TLS 服务(app-pay/app-board/app-pay-admin)用 tcpSocket 探活; +无 HTTP 健康端点的 TLS 服务(app-pay/app-board)用 tcpSocket 探活; app-kg 的健康端点在独立端口 8014(gRPC 数据面 8013)。 ## 5. 日常运维 diff --git a/deploy/k8s/kustomization.yaml b/deploy/k8s/kustomization.yaml index 15700206..bf2e120d 100644 --- a/deploy/k8s/kustomization.yaml +++ b/deploy/k8s/kustomization.yaml @@ -56,8 +56,10 @@ configMapGenerator: - APPBILI__RDBMS__URL=mysql+aiomysql://mind_base:mind-base@mysql:3306/mind_base - APPBOARD__RDBMS__URL=mysql+aiomysql://mind_base:mind-base@mysql:3306/mind_base - APPADMIN__RDBMS__URL=mysql+aiomysql://mind_base:mind-base@mysql:3306/mind_base + # Pod CIDR of the cluster (UI Nginx -> API): trusted for X-Forwarded-*. + - APPADMIN__SECURITY__TRUSTED_PROXIES=10.0.0.0/8 - APPTASK__RDBMS__URL=mysql+aiomysql://app_task:app-task@app-task-mysql:3306/app_task - - PAYADMIN__RDBMS__URL=mysql+aiomysql://app_pay:app-pay@app-pay-mysql:3306/app_pay + - APPADMIN__PAY_RDBMS__URL=mysql+aiomysql://app_pay:app-pay@app-pay-mysql:3306/app_pay - APPTASK_MYSQL_ROOT_PASSWORD=root123 - APPTASK_MYSQL_USER=app_task - APPTASK_MYSQL_PASSWORD=app-task @@ -96,7 +98,6 @@ resources: - 30-apps/app-quiz-mcp.yaml - 30-apps/app-task.yaml - 30-apps/app-pay.yaml - - 30-apps/app-pay-admin.yaml - 30-apps/app-admin.yaml - 30-apps/app-sandbox.yaml - 30-apps/frontend.yaml diff --git a/deploy/swarm/docker-stack.yml b/deploy/swarm/docker-stack.yml index e3fdd1d9..98d7a72e 100644 --- a/deploy/swarm/docker-stack.yml +++ b/deploy/swarm/docker-stack.yml @@ -22,14 +22,14 @@ # 8. networks.driver: bridge → 必须 overlay(多机互通唯一驱动),本文件已声明 # 9. ports 短语法 + 127.0.0.1 回环绑定 → ingress 路由网格不支持宿主 IP 绑定,且会把端口 # 发布到【所有节点】的所有网卡 → 除对外入口(nginx 80/443、 -# higress 控制台、app-pay-admin)外一律不发布端口: +# higress 控制台、app-admin UI)外一律不发布端口: # swarm 服务间互访走 overlay DNS,无需发布端口 # (MySQL/Redis/Mongo/Milvus 调试口临时开放见文件尾注释) # # 使用前提: # 1. docker swarm init(多机:manager 上 init,其余节点 docker swarm join) # 2. 本仓库部署到将运行 bind-mount 服务的节点,绝对路径记为 MB_ROOT(默认 /srv/mindbase); -# 至少需要:nginx/ apisix/ db/ secrets/ app-board/ mcp/app-board-mcp/ app-pay/ app-pay-admin/ data/ +# 至少需要:nginx/ apisix/ db/ secrets/ app-board/ mcp/app-board-mcp/ app-pay/ data/ # 3. .env 与单机 compose 用法一致;⚠️ APISIX_CONSUMER_KEY / PAY_OPS_KEY 必填—— # stack 插值没有 compose 的 :? 语法兜底,留空 = 服务间 key-auth 鉴权失败 # @@ -812,7 +812,7 @@ services: - APPTASK__LOG__OUTPUT=both - HTTP_PROXY=${APPTASK_HTTP_PROXY:-} - HTTPS_PROXY=${APPTASK_HTTP_PROXY:-} - - NO_PROXY=${APPTASK_NO_PROXY:-localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-pay-admin,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,.local,.svc,.internal} + - NO_PROXY=${APPTASK_NO_PROXY:-localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,.local,.svc,.internal} volumes: - app_task_logs:/app/logs # app-pay 开发 CA(cert-gen 产出;缺失时仅 https 委托回调报错、兜底轮询接管) @@ -834,7 +834,7 @@ services: - mind-base-net # ============================================================== - # 支付域(app-pay + app-pay-admin) + # 支付域(app-pay;管理能力已并入 app-admin,见下方「管理后台」段) # 注:application*.yaml 占位符消费的 ALIPAY_*/PAY_EXECUTOR_TOKEN 等 # 原来靠 env_file 注入,这里全部显式内联 # ============================================================== @@ -876,29 +876,65 @@ services: networks: - mind-base-net - # app-pay-admin —— 支付后台管理(不经 APISIX,浏览器直连) - # ⚠️ 单机 compose 绑 127.0.0.1;swarm ingress 会发布到【所有节点 IP】, - # 务必用防火墙/安全组限源 IP,或改走 SSH 隧道后删除本 ports 段 - app-pay-admin: - image: atoncooper/mind-base-app-pay-admin:latest - command: ["-config", "config.docker.yaml"] + # ============================================================== + # 管理后台(app-admin;原 app-pay-admin 已并入,2026-11-03) + # 统一入口 = UI(Nginx SPA + /api 同源代理)+ API(Go,8016,仅 overlay 内可达) + # 页面:分享审核 / 用户 / 沙箱 / 支付运营 / 账户 / 审计 + # ============================================================== + app-admin-api: + image: atoncooper/mind-base-app-admin:latest environment: - - PAYADMIN__RDBMS__URL=mysql+aiomysql://${PAY_MYSQL_USER:-app_pay}:${PAY_MYSQL_PASSWORD:-app-pay}@app-pay-mysql:3306/app_pay - - PAYADMIN__APP_PAY__OPS_KEY=${PAY_OPS_KEY:-} - - PAYADMIN__APP_PAY__BASE_URL=http://apisix:9080 - - PAYADMIN__WEBUI__TOKEN=${PAYADMIN__WEBUI__TOKEN:-} + # 共享 bilirag 库(与 app-auth/app-cloud 同库;RDBMS__URL 来自 .env) + - APPADMIN__RDBMS__URL=${RDBMS__URL:-mysql+aiomysql://mind_base:mind-base@mysql:3306/mind_base} + # 种子账户(只插缺失;首登强制改密)。生产务必覆盖默认口令。 + - APPADMIN__SEED__USERNAME=${APPADMIN__SEED__USERNAME:-admin} + - APPADMIN__SEED__PASSWORD=${APPADMIN__SEED__PASSWORD:-app-admin} + - APPADMIN__SECURITY__IP_ALLOWLIST=${APPADMIN__SECURITY__IP_ALLOWLIST:-} + # 可信反向代理(overlay 网段):只有这些 peer 的 X-Forwarded-* 被采信, + # 用于登录限流、会话 IP/设备绑定与审计;空 = 永不信任转发头。 + - APPADMIN__SECURITY__TRUSTED_PROXIES=${APPADMIN__SECURITY__TRUSTED_PROXIES:-10.0.0.0/8,172.16.0.0/12} + # 分享内容受控预览(app-cloud 内部端点;两边同值 = 开启) + - APPADMIN__CLOUD_REVIEW__BASE_URL=${APPADMIN__CLOUD_REVIEW__BASE_URL:-http://app-cloud:8007} + - APPADMIN__CLOUD_REVIEW__TOKEN=${APP_CLOUD_ADMIN_REVIEW_TOKEN:-} + # 沙箱管理面代理(app-sandbox /internal/admin/*) + - APPADMIN__SANDBOX_ADMIN__BASE_URL=${APPADMIN__SANDBOX_ADMIN__BASE_URL:-http://app-sandbox:8017} + - APPADMIN__SANDBOX_ADMIN__TOKEN=${SANDBOX_ADMIN_TOKEN:-} + # 支付运营(原 app-pay-admin 能力并入):第二 GORM 连接直连 app_pay 库。 + # 留空 = 支付管理未配置(/api/pay/* 503),不影响分享审核/账户/沙箱。 + - APPADMIN__PAY_RDBMS__URL=${PAY_ADMIN_RDBMS_URL:-mysql+aiomysql://${PAY_MYSQL_USER:-app_pay}:${PAY_MYSQL_PASSWORD:-app-pay}@app-pay-mysql:3306/app_pay} + # 退款复核:经 APISIX pay-ops consumer 调 app-pay internal 端点(不直写库)。 + # 未配置 key 时退款复核整组 404 隐藏;其余支付查询不受影响。 + - APPADMIN__PAY__OPS__BASE_URL=${APPADMIN__PAY__OPS__BASE_URL:-http://apisix:9080} + - APPADMIN__PAY__OPS__KEY=${PAY_OPS_KEY:-} + # 资金审计 JSONL(补偿开通/退款确认哈希链,365 天) + - APPADMIN__PAY__AUDIT_FILE=/app/logs/pay-audit.jsonl + - APPADMIN__LOG__OUTPUT=both volumes: - - app_pay_admin_logs:/app/logs - - ${MB_ROOT:-/srv/mindbase}/app-pay-admin/certs:/app/certs - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8003/health || exit 1"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s + - app_admin_logs:/app/logs + deploy: + replicas: 1 + restart_policy: + condition: any + delay: 5s + update_config: + order: start-first + parallelism: 1 + delay: 10s + monitor: 30s + networks: + mind-base-net: + # 显式短名 alias:UI 的 Nginx 静态 upstream 写的是 app-admin-api:8016 + aliases: + - app-admin-api + + # app-admin —— 统一管理后台 UI(Nginx SPA + /api 同源代理到 app-admin-api:8016) + # ⚠️ 单机 compose 绑 127.0.0.1;swarm ingress 会发布到【所有节点 IP】, + # 务必用防火墙/安全组限源 IP,或改走 SSH 隧道后删除本 ports 段 + app-admin: + image: atoncooper/mind-base-app-admin-ui:latest ports: - - target: 8003 - published: 8003 + - target: 80 + published: 3001 protocol: tcp mode: ingress deploy: @@ -1099,7 +1135,7 @@ volumes: app_task_logs: app_pay_mysql_data: app_pay_logs: - app_pay_admin_logs: + app_admin_logs: app_auth_logs: app_cloud_logs: app_note_logs: diff --git a/docker-compose.client.yml b/docker-compose.client.yml index 1b31a5bf..031a7283 100644 --- a/docker-compose.client.yml +++ b/docker-compose.client.yml @@ -845,7 +845,7 @@ services: app-pay-mysql: image: mysql:8.4 container_name: mind-base-app-pay-mysql - profiles: ["pay", "pay-admin"] + profiles: ["pay"] environment: - MYSQL_ROOT_PASSWORD=${PAY_MYSQL_ROOT_PASSWORD:-root123} - MYSQL_DATABASE=app_pay @@ -907,35 +907,6 @@ services: networks: - mind-base-net - app-pay-admin: - image: atoncooper/mind-base-app-pay-admin:latest - pull_policy: never - build: - context: ./app-pay-admin - dockerfile: Dockerfile - container_name: mind-base-app-pay-admin - profiles: ["pay-admin"] - depends_on: - app-pay-mysql: - condition: service_healthy - env_file: - - .env - command: ["-config", "config.docker.yaml"] - environment: - - PAYADMIN__RDBMS__URL=mysql+aiomysql://${PAY_MYSQL_USER:-app_pay}:${PAY_MYSQL_PASSWORD:-app-pay}@app-pay-mysql:3306/app_pay - - PAYADMIN__APP_PAY__OPS_KEY=${PAY_OPS_KEY:?err_PAY_OPS_KEY_not_set_in_.env} - - PAYADMIN__APP_PAY__BASE_URL=http://apisix:9080 - ports: - - "127.0.0.1:${APP_PAY_ADMIN_PORT:-8003}:8003" - volumes: - - app_pay_admin_logs:/app/logs - - ./app-pay-admin/certs:/app/certs - mem_limit: 192m - logging: *default-logging - restart: unless-stopped - networks: - - mind-base-net - # ---- --profile task:定时出题任务执行器(独立 MySQL + 轮询 scheduler)---- # 不开时:定时出题邮件任务不跑;聊天内出题/批改(app-ts + app-quiz)不受影响 app-task-mysql: @@ -994,7 +965,7 @@ services: - APPTASK__LOG__OUTPUT=both - HTTP_PROXY=${APPTASK_HTTP_PROXY:-} - HTTPS_PROXY=${APPTASK_HTTP_PROXY:-} - - NO_PROXY=${APPTASK_NO_PROXY:-localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-pay-admin,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,host.docker.internal,.local,.svc,.internal} + - NO_PROXY=${APPTASK_NO_PROXY:-localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,host.docker.internal,.local,.svc,.internal} ports: - "127.0.0.1:${APP_TASK_PORT:-8001}:8001" volumes: @@ -1089,7 +1060,6 @@ volumes: app_task_mysql_data: app_pay_mysql_data: app_pay_logs: - app_pay_admin_logs: mysql_data: redis_data: mongo_data: diff --git a/docker-compose.yml b/docker-compose.yml index 51d5969b..1330e2c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -189,8 +189,8 @@ services: app-pay-mysql: image: mysql:8.4 container_name: mind-base-app-pay-mysql - # "pay-admin":让 `--profile pay-admin up` 能连带拉起其依赖的 MySQL - profiles: ["", "pay", "pay-admin", "full"] + # 默认集合 + pay:支付后台已并入 app-admin(app-admin-api 经第二连接直连本库) + profiles: ["", "pay", "full"] environment: - MYSQL_ROOT_PASSWORD=${PAY_MYSQL_ROOT_PASSWORD:-root123} - MYSQL_DATABASE=app_pay @@ -789,7 +789,7 @@ services: context: ./app-go/app-cloud dockerfile: Dockerfile # 多实例就绪:无 container_name(--scale app-cloud=N 直接可用)。后台任务 - # 走 Redis 租约锁、向量化管道走 outbox 认领——扩容不重复执行(§2.14)。 + # 走 Redis 租约锁、向量化管道走 outbox 认领——扩容不重复执行。 profiles: ["", "cloud", "full"] depends_on: mysql: @@ -938,7 +938,7 @@ services: # 宿主机代理示例:APPTASK_HTTP_PROXY=http://host.docker.internal:10808 - HTTP_PROXY=${APPTASK_HTTP_PROXY:-} - HTTPS_PROXY=${APPTASK_HTTP_PROXY:-} - - NO_PROXY=${APPTASK_NO_PROXY:-localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-pay-admin,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,host.docker.internal,.local,.svc,.internal} + - NO_PROXY=${APPTASK_NO_PROXY:-localhost,127.0.0.1,::1,apisix,app-auth,app-pay,app-cloud,app-board,app-board-mcp,app-task-mysql,mysql,mongo,redis,minio,higress,host.docker.internal,.local,.svc,.internal} ports: - "127.0.0.1:${APP_TASK_PORT:-8001}:8001" volumes: @@ -1252,52 +1252,17 @@ services: networks: - mind-base-net - # app-pay-admin —— 支付后台管理(Go + Gin) - # 全直连 app_pay 库(读查询 + 唯一写操作:会员补偿开通,事务内复刻 app-pay - # MembershipService.extend 语义),运行时不依赖 app-pay/APISIX。自带账户体系 - # (payadmin_user + bcrypt),仅回环暴露,不经 APISIX(同 app-task WebUI 先例)。 - app-pay-admin: - image: atoncooper/mind-base-app-pay-admin:latest - pull_policy: never # 本地构建产物;绝不拉 Hub——镜像缺失时先 docker compose build <服务> - build: - context: ./app-pay-admin - dockerfile: Dockerfile - container_name: mind-base-app-pay-admin - profiles: ["pay-admin", "full"] - depends_on: - app-pay-mysql: - condition: service_healthy - env_file: - - .env - # 配置分层:default.yaml(嵌入) < config.docker.yaml(-config 指定) < PAYADMIN__ 环境变量 - # rdbms.url 由下方 environment 注入(复用 .env 的 PAY_MYSQL_*),改口令两边自动对齐 - command: ["-config", "config.docker.yaml"] - environment: - - PAYADMIN__RDBMS__URL=mysql+aiomysql://${PAY_MYSQL_USER:-app_pay}:${PAY_MYSQL_PASSWORD:-app-pay}@app-pay-mysql:3306/app_pay - # 退款复核工作台:PAY_OPS_KEY 只注入本控制台(运营人的 UI 载体), - # 资金写经 app-pay 端点执行(审计/状态机一致);其余业务容器不下发该密钥。 - - PAYADMIN__APP_PAY__OPS_KEY=${PAY_OPS_KEY:?err_PAY_OPS_KEY_not_set_in_.env} - - PAYADMIN__APP_PAY__BASE_URL=http://apisix:9080 - ports: - # 仅回环:管理后台不对公网暴露,本机浏览器直连 http://127.0.0.1:8003 - - "127.0.0.1:${APP_PAY_ADMIN_PORT:-8003}:8003" - volumes: - # 运行日志 + 资金审计 JSONL(pay-admin-audit.jsonl,补偿开通留痕) - - app_pay_admin_logs:/app/logs - # 启用容器内 HTTPS:.env 设 PAYADMIN__SERVER__TLS__ENABLED=true 即可 - # (不配 cert/key 时容器启动自动生成自签证书写入 /app/certs;挂卷持久化, - # 重建容器不再换证书)。显式指定文件用 PAYADMIN__SERVER__TLS__CERT/KEY。 - - ./app-pay-admin/certs:/app/certs - restart: unless-stopped - networks: - - mind-base-net - - # app-admin-api —— 分享审核后台 API(Go + Gin;plan/1.0.16) + # app-admin-api —— 统一管理后台 API(Go + Gin;分享审核 + 支付运营) # 直连共享 bilirag MySQL:只读 users / rbac_* / cloud_* 元数据 + 仅 UPDATE # cloud_shares 的 moderation 四列(绝不触碰 is_revoked;绝不 AutoMigrate—— # 自有 admin_user / admin_session / admin_audit 三表自建,有守护测试)。 # 直接查询共享 MySQL(不经 app-cloud API);仅回环调试暴露 8016。前端统一入口 # 是 app-admin 服务(3001),它依赖本 API 和必要 schema owner。 + # 支付域(原 app-pay-admin 已并入,2026-11-03):新增第二 GORM 连接直连 + # app_pay 库(只读订单/会员/权益事件/回调/SKU + 唯一资金写「会员补偿开通」); + # 退款复核经 APISIX pay-ops consumer 调 app-pay internal 端点执行(不直写)。 + # 统一账户/会话/角色/审计:admin_user + admin_session + admin_audit; + # 支付库未配置时 /api/pay/* 返回 503,分享审核/账户/沙箱不受影响。 # 种子账户 admin / app-admin(APPADMIN__SEED__ # 可覆盖,只插缺失),首登强制改密(must_change_password=1)。 app-admin-api: @@ -1306,6 +1271,9 @@ services: build: context: ./app-go/app-admin dockerfile: Dockerfile + args: + # Override with $env:GOPROXY on restricted networks; default keeps goproxy.cn. + GOPROXY: "${GOPROXY:-https://goproxy.cn,direct}" container_name: mind-base-app-admin-api profiles: ["", "admin", "full"] depends_on: @@ -1320,6 +1288,9 @@ services: app-sandbox: condition: service_started required: false # sandbox 管理是可选面板;未启动时管理页显示未配置 + app-pay-mysql: + condition: service_healthy + required: false # 支付运营是可选面板;未启用支付时 /api/pay/* 返回 503 env_file: - .env environment: @@ -1330,6 +1301,11 @@ services: - APPADMIN__SEED__PASSWORD=${APPADMIN__SEED__PASSWORD:-app-admin} # Optional service-side peer IP/CIDR allowlist (comma-separated). - APPADMIN__SECURITY__IP_ALLOWLIST=${APPADMIN__SECURITY__IP_ALLOWLIST:-} + # Trusted reverse-proxy CIDRs: only requests whose peer is in this list may + # supply X-Forwarded-For / X-Forwarded-Proto (real client IP for login + # throttling, device/IP session binding and audit). Compose UI proxy lives + # on the Docker bridge network; empty = never trust forwarded headers. + - APPADMIN__SECURITY__TRUSTED_PROXIES=${APPADMIN__SECURITY__TRUSTED_PROXIES:-172.16.0.0/12} # Share-content review preview via app-cloud (Issue #154): base URL # defaults to the compose-internal app-cloud; token must equal # APP_CLOUD_ADMIN_REVIEW_TOKEN. Both empty = preview disabled. @@ -1340,6 +1316,15 @@ services: # SANDBOX_ADMIN_TOKEN. Both empty = sandbox admin disabled (503). - APPADMIN__SANDBOX_ADMIN__BASE_URL=${APPADMIN__SANDBOX_ADMIN__BASE_URL:-http://app-sandbox:8017} - APPADMIN__SANDBOX_ADMIN__TOKEN=${SANDBOX_ADMIN_TOKEN:-} + # 支付运营(原 app-pay-admin 能力并入):第二 GORM 连接直连 app_pay 库。 + # 留空 = 支付管理未配置(/api/pay/* 503),不影响分享审核/账户/沙箱。 + - APPADMIN__PAY_RDBMS__URL=${PAY_ADMIN_RDBMS_URL:-mysql+aiomysql://${PAY_MYSQL_USER:-app_pay}:${PAY_MYSQL_PASSWORD:-app-pay}@app-pay-mysql:3306/app_pay} + # 退款复核:经 APISIX pay-ops consumer 调 app-pay internal 端点(不直写库)。 + # 未配置 key 时退款复核整组 404 隐藏;其余支付查询不受影响。 + - APPADMIN__PAY__OPS__BASE_URL=${APPADMIN__PAY__OPS__BASE_URL:-http://apisix:9080} + - APPADMIN__PAY__OPS__KEY=${PAY_OPS_KEY:-} + # 资金审计 JSONL(补偿开通/退款确认哈希链,365 天),落 app_admin_logs 卷 + - APPADMIN__PAY__AUDIT_FILE=/app/logs/pay-audit.jsonl - APPADMIN__LOG__OUTPUT=both ports: # 仅回环:管理后台不对公网暴露,本机浏览器直连 http://127.0.0.1:8016 @@ -1350,7 +1335,8 @@ services: networks: - mind-base-net - # app-admin —— 管理后台完整入口(Nginx 静态 SPA + /api 同源代理)。 + # app-admin —— 统一管理后台入口(Nginx 静态 SPA + /api 同源代理)。 + # 页面:分享审核 / 用户 / 沙箱 / 支付运营(订单·会员·权益事件·SKU·补偿开通·退款复核)/ 账户 / 审计。 # `docker compose up app-admin` 自动启动 UI、API、MySQL 及 schema 依赖。 # 浏览器只访问回环 3001;API 调试口 8016 也仅回环,不开放公网。 app-admin: @@ -1449,7 +1435,6 @@ volumes: app_pay_logs: app_pay_test_mysql_data: app_pay_test_logs: - app_pay_admin_logs: app_admin_logs: mysql_data: redis_data: diff --git a/docs/README.md b/docs/README.md index 37a6d2eb..ba4cdc9a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,8 +43,7 @@ | [app-board.md](app-board.md) | 思维导图/白板存储服务:API、乐观锁、缓存、TLS、配置 | | [app-board-mcp.md](app-board-mcp.md) | 思维导图/白板 MCP server:工具面、认证、限流、TLS、服务模式接入 | | [app-pay.md](app-pay.md) | 交易/会员服务:订单状态机、支付渠道、审计、TLS、配置 | -| [app-pay-admin.md](app-pay-admin.md) | 支付后台管理:查询、补偿开通、鉴权、界面 | -| [app-admin.md](app-admin.md) | 云盘分享审核后台:封杀/解封/放行、用户只读、审计、部署 | +| [app-admin.md](app-admin.md) | 统一管理后台:分享审核(封杀/解封/放行)、支付运营(订单/会员/权益事件/SKU/补偿开通/退款复核)、沙箱、统一账户与审计、部署 | | [app-cloud.md](app-cloud.md) | 云盘服务:分块上传、配额记账、分享/回收站、向量化管道、多实例扩容 | | [app-kg.md](app-kg.md) | 知识图谱:app-kg(gRPC 存储)+ app-ts(AI 编排)、proto 契约、已知取舍 | diff --git a/docs/api.md b/docs/api.md index bb2794c1..7204cbd0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -30,7 +30,7 @@ APISIX :9080(forward-auth / key-auth / 路由 / 限流) |------|------| | 本地开发 | `frontendv2` dev server :3000,`next.config.ts` rewrites 把 API 转发到 APISIX :9080 | | Docker 全栈 | nginx :3000(或 80 / 443),页面与 API 同源 | -| 管理台 | 回环 `http://127.0.0.1:3001`,UI 的 `/api` 同源代理到 app-admin-api :8016(不经主站 nginx / APISIX) | +| 管理台 | 统一后台(分享审核 / 支付运营 / 沙箱 / 账户 / 审计):回环 `http://127.0.0.1:3001`,UI 的 `/api` 同源代理到 app-admin-api :8016(不经主站 nginx / APISIX) | | 沙箱管理 | app-admin → app-sandbox `/internal/admin/*`(服务间共享令牌) | --- @@ -42,7 +42,7 @@ APISIX :9080(forward-auth / key-auth / 路由 / 限流) | `forward-auth` | 用户端点 | APISIX 调 app-auth `POST /internal/auth/verify` 宽验证会话;有效则**剥离客户端伪造的头**后注入 `X-Uid` / `X-Roles` | | `key-auth` | 服务间 / 内部端点 | `apikey` 请求头(`APISIX_CONSUMER_KEY`);覆盖 `/internal/*`、MCP、app-task 回调、沙箱执行面 | | 免登录 | 公开面 | 分享访问页(`/cloud/s/*`、`/notes/shared/*`、quiz 分享)、`/pay/callback/*` 渠道验签 | -| Session Cookie | 管理台 | app-admin 自建账户体系(`admin_user` / `admin_session`),登录限流 + 首登强制改密 | +| Session Cookie | 管理台 | app-admin 统一账户体系(`admin_user` / `admin_session`,角色 admin/viewer),登录限流 + 首登强制改密;支付运营与账户管理共用同一会话 | | 服务自校验 | app-sandbox | 除 APISIX key-auth 外,服务内再验 `X-API-Key`,直连无法绕过 | **约定**:下游服务信任网关注入的 `X-Uid` / `X-Roles`,**不自验会话**;身份类参数永远不出现在 LLM 可写参数里(MCP 工具面亦不暴露 uid)。 @@ -70,7 +70,7 @@ APISIX :9080(forward-auth / key-auth / 路由 / 限流) | `/academic/*` | app-ts | 科研项目 / 文献 / 文档章节 / BibTeX 与 Markdown/LaTeX 导出 / 项目事件 | | `/internal/*` | 各服务 | 服务间端点(key-auth);不暴露到 nginx | | `/internal/sandbox/*` | app-sandbox | 沙箱执行面(app-ts 调);管理面仅 app-admin 可调 | -| `/api/*` | app-admin-api | 管理台 API(分享审核 / 用户只读 / 审计 / 沙箱代理),仅回环 | +| `/api/*` | app-admin-api | 管理台 API(分享审核 / 用户只读 / 审计 / 沙箱代理 / 支付运营 `/api/pay/*` / 账户管理 `/api/console/users`),仅回环 | --- diff --git a/docs/app-admin.md b/docs/app-admin.md index d119275b..8dd922ce 100644 --- a/docs/app-admin.md +++ b/docs/app-admin.md @@ -1,17 +1,20 @@ -# app-admin — 云盘分享审核管理后台 +# app-admin — 统一管理后台(分享审核 + 支付运营 + 沙箱 + 账户 + 审计) > 管理后台由 `app-admin`(Nginx 静态 UI,`:3001`,仅回环)+ `app-admin-api`(Go + Gin,`:8016`,仅回环)组成;UI 经同源 `/api` 代理到 API,不经过主站公网 Nginx/APISIX。 -> 业务域 = **云盘分享先分享后审核**(plan/1.0.16):只读用户账户与分享元数据,唯一写操作是 `cloud_shares` 的 4 个审核列。 -> 内容能力 = **受控预览**(Issue #154,2026-10):admin 角色可依法预览分享内文件(用户创建分享时已单独确认告知);**无下载、绝不直连 MinIO**(预览经 app-cloud 内部端点返回:媒体/pdf 为短时效内联授权,文档为后端提取的纯文本)。 +> **2026-11-03 起支付域合并**:原独立服务 `app-pay-admin`(Go + SSR WebUI,:8003,profile `pay-admin`)已退役,支付运营能力(订单/会员/权益事件/SKU 查询、会员补偿开通、退款复核)并入本服务;统一账户(`admin_user`)、统一会话(`admin_session`)、统一角色(`admin`/`viewer`)、统一审计(`admin_audit` + 资金 JSONL)。 +> 业务域 = 云盘分享**先分享后审核**(plan/1.0.16)+ 支付域**只读运营查询 + 两类资金写**(会员补偿开通、退款复核审批)+ 沙箱控制层管理面 + 控制台账户管理。 +> 内容能力 = **受控预览**(Issue #154):admin 角色可依法预览分享内文件(用户创建分享时已单独确认告知);**无下载、绝不直连 MinIO**。 ## 定位与红线 -- **数据访问**:直连共享 bilirag MySQL(`RDBMS__URL` 同 app-auth/app-cloud)。读 `users` / `user_profile` / `rbac_user_role` / `rbac_role` / `cloud_shares` / `cloud_files` / `cloud_folders`;**唯一写** = `cloud_shares` 的 `moderation_status/moderation_reason/moderation_time/moderation_by`(与 `admin_audit` 同事务)。 -- **受控内容预览(Issue #154,审核员可查看)**:仅 admin 角色;app-admin 校验分享存在 + 文件在分享范围(文件分享 = 目标文件;文件夹分享 = 子树存活成员)并落 `admin_audit`(action=`preview_content`),再调 app-cloud `GET /internal/cloud/admin-review/shares/{id}/files/{fid}/preview`(Bearer 共享令牌 `APP_CLOUD_ADMIN_REVIEW_TOKEN`,两边同值)。**审核员可查看任意已存在的分享**(含 banned / 已撤销 / 已过期 / 历史未确认告知的分享——举报常在属主撤销后才到);app-cloud 只校验分享存在与文件归属范围,不校验公开可访问性,也不要求用户确认告知;每次预览先落审计再签发授权。预览返回:pdf / 图片 / 音视频 → 短时效内联 URL(`/minio-proxy/...`,admin UI 内置 Nginx 同源代理到 MinIO,PDF 以 `X-Frame-Options: SAMEORIGIN` 允许同源 iframe 展示);docx / xlsx / pptx / html / markdown / txt 等 → 后端提取的纯文本(`viewMode:"text"`,xlsx 按单元格顺序输出、XML 实体自动解码),不生成对象 URL、不渲染 HTML(防存储型 XSS);暂不支持类型 / 无文本(扫描件/加密件)/ 过大文件返回 422/413 并带原因。viewer 角色一律 403。 +- **数据访问(主库)**:直连共享 bilirag MySQL(`RDBMS__URL` 同 app-auth/app-cloud)。读 `users` / `user_profile` / `rbac_user_role` / `rbac_role` / `cloud_shares` / `cloud_files` / `cloud_folders`;**写** = `cloud_shares` 的 `moderation_status/moderation_reason/moderation_time/moderation_by`(与 `admin_audit` 同事务)。 +- **数据访问(支付库)**:第二 GORM 连接直连 `app_pay`(`app-pay-mysql` 实例)。只读 `pay_order` / `pay_membership` / `pay_membership_event` / `pay_callback_log` / `pay_product` / `pay_refund_approval`;**唯一资金写** = 会员补偿开通(直写 `pay_membership` + `pay_membership_event`,同事务);退款复核**不直写**,经 app-pay internal 端点执行。`pay_*` schema 归 Java(app-pay/Flyway)所有,**本服务绝不 AutoMigrate**(守护测试锁定:只建三张自有表,DDL 禁列包含 `pay_*`)。 +- **支付模块关闭条件**(任一):未配置 `pay_rdbms.url`,或 PayDB 初始化失败 → 所有 `/api/pay/**` 返回 503 `{"detail":"支付管理未配置"}`;分享审核/用户/沙箱/账户/审计不受影响。 +- **受控内容预览(Issue #154,审核员可查看)**:仅 admin 角色;app-admin 校验分享存在 + 文件在分享范围(文件分享 = 目标文件;文件夹分享 = 子树存活成员)并落 `admin_audit`(action=`preview_content`),再调 app-cloud `GET /internal/cloud/admin-review/shares/{id}/files/{fid}/preview`(Bearer 共享令牌 `APP_CLOUD_ADMIN_REVIEW_TOKEN`,两边同值)。**审核员可查看任意已存在的分享**(含 banned / 已撤销 / 已过期 / 历史未确认告知的分享);app-cloud 只校验分享存在与文件归属范围,不校验公开可访问性。预览返回:pdf / 图片 / 音视频 → 短时效内联 URL(`/minio-proxy/...`,admin UI 内置 Nginx 同源代理到 MinIO,PDF 以 `X-Frame-Options: SAMEORIGIN` 允许同源 iframe 展示);docx / xlsx / pptx / html / markdown / txt 等 → 后端提取的纯文本(`viewMode:"text"`,不生成对象 URL、不渲染 HTML,防存储型 XSS);暂不支持类型 / 无文本 / 过大文件返回 422/413 并带原因。viewer 角色一律 403。 - **app-cloud 关系**:admin API **直接查/写 MySQL**,请求不转发给 app-cloud。Compose 启动 app-cloud 是为了让其 schema owner migration 创建/补齐 `cloud_shares` 审核列;这些列已迁移后,admin 的日常查询不依赖 app-cloud 业务 API。全新库首次启动时仍需 app-cloud 完成迁移。 - **自有表**(本服务 `Migrate` 只 `CREATE TABLE IF NOT EXISTS` 这三张,守护测试锁定):`admin_user`(控制台账户)/ `admin_session`(DB 会话)/ `admin_audit`(操作审计)。 -- **绝不** `AutoMigrate`/ALTER 共享表;**绝不** 触碰 `is_revoked`(属主撤销语义独立,admin 只改审核列)。 -- **对象存储边界**:GORM 模型连 `bucket`/`object_key` 都不映射,app-admin 不持有任何 MinIO 凭证;内容只经 app-cloud 内部预览端点返回:媒体/pdf 为短时效内联 URL(经 admin UI Nginx `/minio-proxy/` 同源代理,PDF iframe 按 SAMEORIGIN 放行),文档为提取文本(文本模式无对象 URL 到达浏览器);列表/详情其余字段仍只出 name/size/mimeType/path 元数据。 +- **绝不** `AutoMigrate`/ALTER 共享表与 `pay_*` 表;**绝不** 触碰 `is_revoked`(属主撤销语义独立,admin 只改审核列)。 +- **对象存储边界**:GORM 模型连 `bucket`/`object_key` 都不映射,app-admin 不持有任何 MinIO 凭证;列表/详情只出 name/size/mimeType/path 元数据(受控预览除外)。 - **端口/暴露**:UI `3001`、API 调试口 `8016` 都只绑定 `127.0.0.1`;用户浏览器访问 UI,由同源 Nginx 代理 `/api`。后台不经主站 APISIX/nginx。 - **包结构**:`router`(参数解析/鉴权/错误体)→ `service`(业务规则 + DTO 组装)→ `repo`(SQL);响应 DTO 独立在 `internal/dto`(纯 JSON 契约、无依赖,service 与 router 共用),不放在 service 包里。 @@ -20,74 +23,185 @@ | 路由 | 说明 | |------|------| | `/login` | 登录;`mustChangePassword=true` 时**强制先改密**(POST `/api/me/password`)再进后台 | -| `/shares` | 分享审核:筛选(审核状态 / 属主 uid / 关键词 / 是否已撤销 / **内容状态**)+ 元数据表格 + **每行「查看」详情抽屉**(元数据 + 分享内文件列表 + 受控预览入口)+ **放行 / 封杀 / 解封**(reason 弹窗,封杀必填)。**默认只看「目标内容存在」的可审核分享**,目标已删除的存量记录需切到「内容:全部/目标已删除」查看(仅审计,无可审核内容);预览与审核操作仅 admin;审核员可查看任意分享内容,每次预览落审计 | -| `/users` | 用户只读:uid / email / phone / nickname / status / 角色 Tag;行点击抽屉看角色明细。查询框**自动识别** UID(数字)/ 11 位手机号(1[3-9] 开头)/ 邮箱(含 @),实时显示识别结果;前端正则校验 + 后端二次校验,非法输入不发起请求 | -| `/audit` | 审计日志倒序;操作下拉筛选 + 管理员精确筛选;操作中文标签/颜色,详情按动作格式化(封杀/解封/放行原因、预览文件与模式、登录 IP/客户端、自助改密);「分享 #ID」可跳转并自动打开分享详情抽屉 | - -前端请求层 `src/api/client.ts` 统一:带 cookie、`{detail}` 错误透传、401 清态踢回登录;uid 为 bigint(可 > 2^53),解析时保留为字符串防精度丢失。 +| `/shares` | 分享审核:筛选(审核状态 / 属主 uid / 关键词 / 是否已撤销 / **内容状态**)+ 元数据表格 + **每行「查看」详情抽屉**(元数据 + 分享内文件列表 + 受控预览入口)+ **放行 / 封杀 / 解封**(reason 弹窗,封杀必填)。**默认只看「目标内容存在」的可审核分享**,目标已删除的存量记录需切到「内容:全部/目标已删除」查看(仅审计,无可审核内容);预览与审核操作仅 admin | +| `/users` | 用户只读:uid / email / phone / nickname / status / 角色 Tag;行点击抽屉看角色明细。查询框**自动识别** UID(数字)/ 11 位手机号(1[3-9] 开头)/ 邮箱(含 @),前端正则校验 + 后端二次校验,非法输入不发起请求 | +| `/sandbox` | 沙箱控制层面板:概览 / 执行 / 租约 / 配额 / 审计(详见下文「沙箱管理」) | +| `/pay/orders`、`/pay/orders/:orderNo` | 支付订单列表(订单号/UID/交易号/状态/渠道/创建时间范围)与详情(Descriptions + 回调流水 Table) | +| `/pay/memberships`、`/pay/memberships/:uid/events` | 会员(UID 查询 + 全量分页;行操作「权益流水」→ 单人事件流) | +| `/pay/events` | 全局权益事件流(uid/type 筛选,ADMIN_GRANT 审计用) | +| `/pay/products` | SKU 商品(只读;改价走 app-pay 运营改库流程) | +| `/pay/grants` | 补偿开通(admin):表单 + 结果 Alert + 最近 ADMIN_GRANT 列表 | +| `/pay/refunds` | 退款复核(admin):status 筛选 + approve/reject 弹窗;未配置时显示「未配置」提示 | +| `/accounts` | 控制台账户(admin):列表 / 新建 / 重置密码 / 删除;非 admin 显示 403 Alert 且不发请求 | +| `/audit` | 审计日志倒序;操作下拉筛选 + 管理员精确筛选;操作中文标签/颜色,详情按动作格式化(封杀/解封/放行原因、预览文件与模式、登录 IP/客户端、自助改密、支付/账户动作);「分享 #ID」可跳转并自动打开分享详情抽屉 | + +前端请求层 `src/api/client.ts` 统一:带 cookie、`{detail}` 错误透传、401 清态踢回登录;uid 为 bigint(可 > 2^53),解析时保留为字符串防精度丢失(**支付域 uid 一律 string**,金额 `amountCents/priceCents` 为整数分)。 **查看分享内容**:列表行「查看」→ 详情抽屉;文件行「预览」→ 受控预览(admin)。**审核员可查看任意分享的内容**(包括已撤销、已过期、封杀中、以及创建时用户未确认《管理员内容审核告知》的历史分享),用户告知状态仅作展示、不阻断预览。每次预览后端写 `preview_content` 审计;除受控预览外不提供下载/导出/URL 复制。 -## 鉴权 +## 鉴权(统一账户体系) | 机制 | 说明 | |------|------| | 账户 | `admin_user` + bcrypt;种子账户 **`admin` / `app-admin`**(`APPADMIN__SEED__USERNAME` / `APPADMIN__SEED__PASSWORD` 可覆盖,**只插缺失不覆盖**),首登强制改密(`must_change_password=1`) | | 会话 | DB 会话 `admin_session` + **HttpOnly cookie `admin_session`**(SameSite=Lax;TLS 下自动 Secure);**滑动 12h**(每个认证请求续期),登出只删当前会话 | -| 角色 | `admin`(审核写)/ `viewer`(只读);ban/unban/approve 仅 admin;改密对所有登录账户开放 | -| 限流 | 每 IP 每分钟 10 次登录失败 → 429 + `Retry-After`;进程内限流;`SetTrustedProxies(nil)` 防 XFF 伪造 | -| IP 访问控制 | 可选精确 IP/CIDR 白名单(`APPADMIN__SECURITY__IP_ALLOWLIST`);空列表时仍依赖 loopback / 防火墙 / VPN / ClusterIP;`/health` 为探针豁免 | -| 浏览器来源 | CORS 仅允许同源或显式配置的精确 Origin;跨域预检校验方法/请求头;浏览器 POST/PUT/PATCH/DELETE 必须带允许的 Origin(CSRF 防护),不允许 `*` | -| 加固 | nosniff / `X-Frame-Options: DENY` / CSP `frame-ancestors 'none'` / `/api/*` no-store | +| 角色 | `admin`(全部写:审核 / 补偿开通 / 退款复核 / 账户管理 / 沙箱写)/ `viewer`(只读);改密对所有登录账户开放 | +| 设备指纹 | 所有 `/api/*` 请求必须带 `X-Device-Id`(16–128 位 `[A-Za-z0-9_-]`,浏览器在 localStorage 生成并持久化)。登录会话绑定 `SHA256(v1|deviceId|UA|首选语言)`;`APPADMIN__SESSION__DEVICE_BINDING=strict|id|off`(默认 strict)。设备不一致/缺失 → 删除会话 + 审计 `session_device_mismatch` + 401 | +| IP 绑定 | 会话记录登录 IP,并按 `APPADMIN__SESSION__IP_BINDING=subnet|exact|off`(默认 subnet;IPv4 /24、IPv6 /64,前缀可配)校验;不一致 → 删除会话 + 审计 `session_ip_mismatch` + 401 | +| 可信代理 | `APPADMIN__SECURITY__TRUSTED_PROXIES`(IP/CIDR 列表)内的 TCP peer 才采信 `X-Forwarded-For/X-Forwarded-Proto`;真实客户端 IP 用于限流、审计与会话绑定。空 = 永不信任转发头 | +| 登录限流 | IP 级 10 次失败/分钟 + 账户级 10 次失败/15 分钟(均可配),超限 429 + `Retry-After`;失败写 `login_failed` 审计,新设备/新 IP 写 `login_new_device`/`login_new_ip` | +| IP 访问控制 | 可选精确 IP/CIDR 白名单(`APPADMIN__SECURITY__IP_ALLOWLIST`,**按 TCP peer 判断**,不采信 XFF);空列表时仍依赖 loopback / 防火墙 / VPN / ClusterIP;`/health` 为探针豁免 | +| 浏览器来源 | CORS 仅允许同源或显式配置的精确 Origin;不允许 `*` / `null`;预检只接受 GET/POST/DELETE 与 Content-Type / Authorization / X-Request-Id / **X-Device-Id**;浏览器非安全方法必须带允许的 Origin(CSRF 防护) | +| 加固 | nosniff / `X-Frame-Options: DENY` / CSP `frame-ancestors 'none'` / CORP `same-origin` / COOP `same-origin` / `/api/*` no-store | + +IP 白名单检查应用实际看到的 TCP 对端地址,**不读取 `X-Forwarded-For`**;登录限流、会话 IP 绑定和审计则使用可信代理链路解析出的真实客户端 IP。Compose 默认把 UI 容器所在 Docker 网段(`172.16.0.0/12`)设为可信代理;两机/swarm/k8s 分别使用 overlay/pod 网段。**若改成直连 API(不经 UI Nginx)或代理网段不同,必须同步调整 `APPADMIN__SECURITY__TRUSTED_PROXIES`**,否则 API 只能看到代理地址、IP 绑定退化为代理地址。不要把管理员电脑 IP 写成 API allowlist(应用看到的是代理/隧道地址)。 -IP 白名单检查应用实际看到的 TCP 对端地址,**不读取 `X-Forwarded-For`**。Compose 下 API 看到的是 `app-admin` UI 容器的地址,不是浏览器 IP;不要把管理员电脑的 IP 当成 API allowlist,否则会被拒绝。通过 SSH 隧道、Docker NAT、Kubernetes port-forward 或反向代理访问时,真实用户 IP 应在 SSH 入口、防火墙或可信入口代理处限制。若管理 SPA 与 API 跨域部署,必须配置精确的 CORS Origin;CORS 本身不是网络访问控制。 +**统一账户管理**(`/api/console/users`,admin):列表 / 新建(username 2..64、password 8..128、role 默认 viewer)/ 重置密码(重置后强制改密并删除该账户全部会话)/ 删除(不能删自己、不能删最后一个 admin)。原 `app-pay-admin` 的 `payadmin_user` 账户**不迁移**,运维在本页重建。 ## API 速览 -错误体统一 `{"detail": ...}`;分页 `page` 从 1 起、`pageSize` ≤100、缺省 20。 +错误体统一 `{"detail": ...}`;分页 `page` 从 1 起、`pageSize` ≤100、缺省 20;时间输出 RFC3339。 + +### 分享审核 / 用户 / 审计 | 方法 | 路径 | 角色 | 说明 | |------|------|------|------| -| GET | `/health` | — | 探活(**真查 DB** `SELECT 1`;DB 不可用 503) | -| POST | `/api/login`、`/api/logout` | — | 登录/登出(登录在 session 闸门外) | +| GET | `/health` | — | 探活(主库不可用 503;配置了支付库但 PayDB 不可用 → 503 `pay database unavailable`;否则 `{"status":"healthy","service":"app-admin","pay":"ok"\|"disabled"}`) | +| POST | `/api/login` | — | 登录(session 闸门外);**必须带 `X-Device-Id`**,服务端校验设备指纹并绑定会话 | | GET | `/api/me`;POST `/api/me/password` | 登录 | 会话回显(含 `mustChangePassword`)/ 自助改密(旧密校验 + 新密 ≥8 位) | -| GET | `/api/users?q=&mode=uid\|phone\|email&page=&pageSize=` | viewer+ | 用户列表:q **精确匹配** UID(正整数)/ 11 位手机号(1[3-9] 开头)/ 邮箱(含 @);`mode` 可选,前端按正则判定后传入、后端再次校验(格式或方式不匹配 → 400);不传 mode 时后端自行推断;响应 `queryMode` 回显实际查询方式 | -| GET | `/api/users/:uid` | viewer+ | 详情 + 角色明细(granted/expires/isActive),**不含任何文件/存储信息** | -| GET | `/api/shares?status=active\|approved\|banned&uid=&revoked=0\|1&available=0\|1&q=&page=&pageSize=` | viewer+ | 分享列表:文件/文件夹;文件夹用**一条递归 CTE** 聚合 fileCount/totalSize。`contentAvailable` 表示目标文件/文件夹是否仍存在;`available=1`(UI 审核队列默认)=仅可审核,`0`=仅目标已删除的存量记录,缺省=全部 | -| GET | `/api/shares/:id` | viewer+ | 详情 + `files: [{name,size,mimeType,path}]`(上限 2000,超出 `truncated: true`) | -| POST | `/api/shares/:id/ban` | admin | `reason` 必填 → `moderation_status=banned` + audit;**不触碰 is_revoked** | -| POST | `/api/shares/:id/unban` | admin | → `active`(清 reason/time/by;可选 reason 只进审计)+ audit | -| POST | `/api/shares/:id/approve` | admin | → `approved`(可选 reason)+ audit | -| GET | `/api/shares/:id/files/:file_id/preview` | admin | 受控内容预览:短时效**内联**授权(url/viewMode/mimeType/fileName/fileSize/expiresIn);审核员可查看任意分享(banned/撤销/过期/未确认告知均可);非分享范围或文件已删除 → 404;未配置 → 500 + 提示;每次查看审计 action=`preview_content` | -| GET | `/api/audit?action=&admin=&page=&pageSize=` | viewer+ | 审计倒序(ban/unban/approve/preview_content/login/logout/change_password) | +| GET | `/api/users?q=&mode=uid\|phone\|email&page=&pageSize=` | viewer+ | 用户列表:q **精确匹配** UID / 11 位手机号 / 邮箱;`mode` 前后端双重校验;响应 `queryMode` 回显 | +| GET | `/api/users/:uid` | viewer+ | 详情 + 角色明细,**不含任何文件/存储信息** | +| GET | `/api/shares?status=active\|approved\|banned&uid=&revoked=0\|1&available=0\|1&q=&page=&pageSize=` | viewer+ | 分享列表:文件/文件夹;文件夹用一条递归 CTE 聚合 fileCount/totalSize;`contentAvailable` 标记目标是否仍存在 | +| GET | `/api/shares/:id` | viewer+ | 详情 + `files: [{name,size,mimeType,path}]`(上限 2000,`truncated`) | +| POST | `/api/shares/:id/ban` | admin | `reason` 必填 → `banned` + audit;**不触碰 is_revoked** | +| POST | `/api/shares/:id/unban` | admin | → `active`(清 reason/time/by)+ audit | +| POST | `/api/shares/:id/approve` | admin | → `approved` + audit | +| GET | `/api/shares/:id/files/:file_id/preview` | admin | 受控内容预览(短时效内联授权;非分享范围/已删除 404;未配置 500;每次审计 `preview_content`) | +| POST | `/api/logout` | 登录 | 登出当前会话 | +| GET | `/api/audit?action=&admin=&page=&pageSize=` | viewer+ | 审计倒序(含登录失败/新设备/新 IP/会话不匹配、`pay_grant` / `pay_refund_approve` / `pay_refund_reject` / `account_*` / 沙箱写动作) | + +### 支付运营(`/api/pay/**`;支付模块关闭时整组 503) + +| 方法 | 路径 | 角色 | 说明 | +|------|------|------|------| +| GET | `/api/pay/orders?uid=&status=&channel=&sku=&orderNo=&tradeNo=&createdFrom=&createdTo=&page=&pageSize=` | viewer+ | 订单列表(`ORDER BY id DESC` 主键序);uid 输出 string;金额为整数分 | +| GET | `/api/pay/orders/:orderNo?tradeNo=` | viewer+ | 订单详情;orderNo 查不到时 tradeNo 兜底;找不到 404 `order not found` | +| GET | `/api/pay/orders/:orderNo/callbacks?page=&pageSize=` | viewer+ | 回调流水(脱敏报文 + 验签/处理结论) | +| GET | `/api/pay/memberships?uid=` / `?page=&pageSize=` | viewer+ | 带 uid → 单个会员(无记录 `null`);不带 uid → 分页列表(`active` 懒过期 = expireAt>now) | +| GET | `/api/pay/memberships/:uid/events?page=&pageSize=` | viewer+ | 单人权益事件流 | +| GET | `/api/pay/events?uid=&type=&page=&pageSize=` | viewer+ | 全局权益事件流(ADMIN_GRANT 审计用) | +| GET | `/api/pay/products` | viewer+ | SKU 目录(只读;sort ASC,id ASC) | +| POST | `/api/pay/grants` | admin | 会员补偿开通(见下节「资金写红线」) | +| GET | `/api/pay/refunds?status=PENDING\|APPROVED\|REJECTED\|ALL&page=&pageSize=` | admin | 退款复核列表(直读 `pay_refund_approval`,id DESC);未配置整组 404 `refund review not configured` | +| POST | `/api/pay/refunds/:orderNo/approve` | admin | 经 app-pay internal 端点执行退款(`confirm=true`,operator=登录用户名);上游失败 502 | +| POST | `/api/pay/refunds/:orderNo/reject` | admin | 驳回(同上通路) | + +### 控制台账户(`/api/console/users`,admin;不依赖支付库) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/console/users` | 账户列表(username ASC;`mustChangePassword`/最后登录/创建时间) | +| POST | `/api/console/users` | 新建(409 `username already exists`) | +| POST | `/api/console/users/:id/password` | 重置密码(强制改密 + 删会话;404 不存在) | +| DELETE | `/api/console/users/:id` | 删除(不能删自己 / 最后一个 admin;级联删会话) | + +> 既有 `GET /api/users`(业务用户只读)保持不变;账户管理路径固定为 `/api/console/users`,避免冲突。 + +## 支付运营:资金写红线 + +### 会员补偿开通(grant) + +`POST /api/pay/grants`(admin):`{"uid": , "durationDays": 1..3650, "reason": 必填, "tier"?: "VIP"|"SVIP"}`。校验:uid>0;reason trim 非空;`"[操作人] reason"` 总长 ≤ 200 rune(列宽)。 + +Go 侧**复刻** app-pay `MembershipService.extend` 语义:事务内 `SELECT ... FOR UPDATE` 会员行(sqlite 测试跳过)→ 无记录则新建(默认 tier=VIP)→ 未到期从 `expire_at` 顺延、已过期从 now 起算 → 同事务写 `pay_membership` + `pay_membership_event(type=ADMIN_GRANT)`;`last_order_no` 保留不清。 + +**审计三留痕**: + +1. `pay_membership_event.reason` 以 `[操作人]` 前缀记录(Java 表无 operator 列,schema 不漂移); +2. 资金审计 JSONL `logs/pay-audit.jsonl`(事件 `MEMBERSHIP_EXTENDED`,哈希链,365 天,lumberjack 轮转); +3. `admin_audit` action=`pay_grant` targetType=`membership`(跨库无法同事务,失败仅告警日志,不返回失败——资金已落库)。 + +**日额度熔断**(对齐 app-pay):`pay.grant.daily_max_count` / `daily_max_days`(按 `type=ADMIN_GRANT AND created_at>=本地当天0点` 的 COUNT/SUM 判定;0=不限);超限 429 并写 JSONL `GRANT_DAILY_COUNT_EXCEEDED` / `GRANT_DAILY_DAYS_EXCEEDED`;统计失败 fail-open + 告警。 + +### 退款复核(未配置 → 整组 404 隐身) + +配置 `pay.ops.base_url` + `pay.ops.key` 且 `pay.ops.refund_review_enabled=true` 才启用。approve/reject 经 APISIX pay-ops consumer(`PAY_OPS_KEY`)调 app-pay internal 端点执行: + +- `POST {base}/internal/pay/orders/{orderNo}/refund` `{"reason":..., "operator": 登录用户名, "confirm":true}` +- `POST {base}/internal/pay/orders/{orderNo}/refund/reject` `{"operator":...}` + +**operator 由服务端注入**(替代 curl 自报身份),maker != checker 由 app-pay 强制。成功后写 `admin_audit`(`pay_refund_approve` / `pay_refund_reject`)+ JSONL(`REFUND_CONFIRMED` / `REFUND_REJECTED`);上游失败 502。timeout 默认 5s。 + +**禁止**在本服务实现退款/关单/SKU 改价等其余资金写操作(需先在 app-pay 侧建端点保证审计一致);**禁止**绕过 app-pay 直写 `pay_*` 表。 + +## 登录安全(设备指纹 / IP / 跨域) + +- **设备指纹**:浏览器首次访问生成随机 `deviceId` 存 localStorage,并在所有请求带 `X-Device-Id`;服务端不落原始值,只存 `SHA256(v1|deviceId|UA|首选语言)` 到 `admin_session.device_hash`。换浏览器/清存储/UA 变化(strict 模式)会要求重新登录;会话被复制到其他设备时因指纹不符立即失效。 +- **IP 绑定**:登录时记录真实客户端 IP,后续请求按 `subnet`(IPv4 /24、IPv6 /64)或 `exact` 校验;跨网段/换 IP 会失效并写 `session_ip_mismatch`。 +- **可信代理**:只有 `APPADMIN__SECURITY__TRUSTED_PROXIES` 内的 peer 才能通过 `X-Forwarded-For/X-Forwarded-Proto` 提供真实 IP;直连 API 的 `X-Forwarded-For` 一律忽略。Compose 默认信任 UI 容器网段,其他部署按实际网段配置。 +- **限流与审计**:IP 级 + 账户级双预算(默认 10 次/分钟/IP、10 次/15 分钟/账户);失败写 `login_failed`,首次出现的新设备/新 IP 写 `login_new_device` / `login_new_ip`;会话设备/IP 校验失败写 `session_device_mismatch` / `session_ip_mismatch` 并删除会话。 +- **跨域**:CORS 只接受同源或 `APPADMIN__SECURITY__CORS__ALLOW_ORIGINS` 的精确 Origin(不允许 `*` / `null`);预检仅放行 GET/POST/DELETE 与白名单请求头(含 `X-Device-Id`);非安全方法必须带允许的 Origin(CSRF 纵深防御)。生产建议 UI 与 API 同源(Compose 已通过 UI Nginx 反代实现)。 +- **脚本/API 调用**:先 `POST /api/login`(带 `X-Device-Id`)拿 `admin_session` cookie,之后每次请求同时带 cookie 与相同 `X-Device-Id`;换机器/脚本需使用新的设备 ID 并重新登录。 + +```bash +DEVICE_ID="$(openssl rand -hex 24)" +# 非安全方法必须带同源 Origin(CSRF 防线);X-Device-Id 必填。 +curl -c cookies.txt -H "Origin: http://127.0.0.1:8016" -H "X-Device-Id: $DEVICE_ID" \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"..."}' http://127.0.0.1:8016/api/login +curl -b cookies.txt -H "X-Device-Id: $DEVICE_ID" http://127.0.0.1:8016/api/me +``` + +```text +设备/IP 不匹配统一返回 401 {"detail":"login required"}(不区分原因,避免探测);具体原因只落审计。 +``` ## 审核如何生效 - 公开访问 `GET /cloud/s/{token}` 由 app-cloud `ResolveShare` 判定:`moderation_status=banned` 与 `is_revoked` **同等统一 404** → admin 写共享库后**即时生效**(无需回调/重载)。 -- 引用面(`cloud_refs` 解析)同时校验来源分享未 banned/revoked/过期 → 封杀后已保存引用也立即失效(404「引用已失效」并落 dead)。 -- 属主列表(`GET /cloud/video/:uuid/shares` / `/cloud/folders/:id/shares`)返回审核三元组(status/reason/time),属主可见审核结果。 +- 引用面(`cloud_refs` 解析)同时校验来源分享未 banned/revoked/过期 → 封杀后已保存引用也立即失效。 +- 属主列表返回审核三元组(status/reason/time),属主可见审核结果。 ## 配置 -三层合并(后者覆盖前者):`default.yaml`(go:embed 嵌入)← `-config` 覆盖层 ← `APPADMIN__` 环境变量(最高);另经 godotenv 读项目根 `.env`。 +三层合并(后者覆盖前者):`default.yaml`(go:embed 嵌入)← `-config` 覆盖层 ← `APPADMIN__` 环境变量(最高);另经 godotenv 读项目根 `.env`。时区固定 Asia/Shanghai(DSN `loc=Local`)。 ```bash APPADMIN__RDBMS__URL=mysql+aiomysql://mind_base:mind-base@mysql:3306/mind_base APPADMIN__SEED__USERNAME=admin # 种子账户(只插缺失) APPADMIN__SEED__PASSWORD=app-admin # 生产务必覆盖 APPADMIN__SESSION__TTL_MINUTES=720 # 会话滑动 TTL(默认 12h) -APPADMIN__SECURITY__IP_ALLOWLIST= # 可选,逗号分隔 IP/CIDR;例:10.0.0.10/32,192.168.1.0/24 -APPADMIN__SECURITY__CORS__ALLOW_ORIGINS= # 可选,逗号分隔精确 Origin;例:https://admin.example.com -# 内容审核预览(与 app-cloud 共享令牌,两边同值 = 预览开启;单侧配置或都为空 = 预览关闭) +APPADMIN__SESSION__DEVICE_BINDING=strict # strict | id | off(默认 strict,设备指纹绑定) +APPADMIN__SESSION__IP_BINDING=subnet # exact | subnet | off(默认 subnet) +APPADMIN__SESSION__IPV4_PREFIX=24 # subnet 模式 IPv4 前缀 +APPADMIN__SESSION__IPV6_PREFIX=64 # subnet 模式 IPv6 前缀 +APPADMIN__SECURITY__TRUSTED_PROXIES=172.16.0.0/12 # 可信代理 CIDR(采信 X-Forwarded-*) +APPADMIN__SECURITY__LOGIN__MAX_FAILS_PER_IP=10 +APPADMIN__SECURITY__LOGIN__IP_WINDOW_SECONDS=60 +APPADMIN__SECURITY__LOGIN__MAX_FAILS_PER_ACCOUNT=10 +APPADMIN__SECURITY__LOGIN__ACCOUNT_WINDOW_SECONDS=900 +APPADMIN__SECURITY__IP_ALLOWLIST= # 可选,逗号分隔 IP/CIDR +APPADMIN__SECURITY__CORS__ALLOW_ORIGINS= # 可选,逗号分隔精确 Origin +# 内容审核预览(与 app-cloud 共享令牌,两边同值 = 预览开启) # APPADMIN__CLOUD_REVIEW__BASE_URL=http://app-cloud:8007 # APPADMIN__CLOUD_REVIEW__TOKEN= +# 沙箱管理面(与 app-sandbox SANDBOX__ADMIN__TOKEN 同值) +APPADMIN__SANDBOX_ADMIN__BASE_URL=http://app-sandbox:8017 +APPADMIN__SANDBOX_ADMIN__TOKEN=<与 SANDBOX__ADMIN__TOKEN 相同> +# 支付运营(原 app-pay-admin):第二 GORM 连接直连 app_pay 库 +APPADMIN__PAY_RDBMS__URL=mysql+aiomysql://app_pay:app-pay@app-pay-mysql:3306/app_pay +# 退款复核:经 APISIX pay-ops consumer 调 app-pay internal 端点 +APPADMIN__PAY__OPS__BASE_URL=http://apisix:9080 +APPADMIN__PAY__OPS__KEY=<与 PAY_OPS_KEY 同值;留空 = 退款复核 404 隐藏> +# 资金审计 JSONL(补偿开通/退款确认哈希链,365 天) +APPADMIN__PAY__AUDIT_FILE=/app/logs/pay-audit.jsonl APPADMIN__LOG__LEVEL=info # debug|info|warn|error;format: text|json APPADMIN__LOG__OUTPUT=both # stdout|file|both(compose 置 both,落 /app/logs) APP_ADMIN_WEB_PORT=3001 # compose 宿主机 UI 端口(默认 3001,仅回环) APP_ADMIN_PORT=8016 # API 调试映射端口(默认 8016,仅回环) ``` -普通配置(端口/时区/连接池/日志轮转)在 `app-go/app-admin/default.yaml`;时区固定 Asia/Shanghai(DSN `loc=Local`)。 +普通配置(端口/时区/连接池/日志轮转/日额度阈值)在 `app-go/app-admin/default.yaml`;`pay.ops.refund_review_enabled`、`pay.grant.daily_max_count` / `daily_max_days` 亦可在 YAML 调整。 ## 启动 @@ -100,36 +214,40 @@ docker compose up -d --build app-admin # 仅启动 API(不启动后台 UI) docker compose up -d --build app-admin-api -# 本地(Go 模块模式;连宿主 MySQL) +# 本地(Go 模块模式;连宿主 MySQL + 宿主 app-pay MySQL) cd app-go/app-admin -APPADMIN__RDBMS__URL='mysql+aiomysql://mind_base:mind-base@127.0.0.1:3306/mind_base' go run . +APPADMIN__RDBMS__URL='mysql+aiomysql://mind_base:mind-base@127.0.0.1:3306/mind_base' \ +APPADMIN__PAY_RDBMS__URL='mysql+aiomysql://app_pay:app-pay@127.0.0.1:3307/app_pay' go run . # 前端 dev(另一个终端) cd frontend-admin && pnpm i && pnpm dev # http://localhost:3001,/api 代理并改写为上游同源 Origin ``` -> `app-admin` 是组合入口服务,会依赖启动 `app-admin-api`;API 依赖 MySQL、app-auth(用户/RBAC 表)和 app-cloud(审核列 migration)。app-auth/app-cloud 会启动各自的 Redis/MinIO 依赖。确保根目录 `.env`(含 `APISIX_CONSUMER_KEY` 等 Compose 必需值)和 `secrets/api_key_encryption_key` 已配置;首次启动等待 migration 完成后再登录。app-cloud 完成首次审核列 migration 后,admin API 的请求仍是直接操作 MySQL。 +> `app-admin` 是组合入口服务,会依赖启动 `app-admin-api`;API 依赖 MySQL、app-auth(用户/RBAC 表)、app-cloud(审核列 migration)与(可选)app-pay-mysql。支付库/ops key 未配置时支付页显示未配置/503,其余功能正常。首次启动等待 migration 完成后再登录。 -## 部署(deploy/docker 与 k8s) +## 部署(deploy/docker、k8s、swarm) -- 两机 compose:`deploy/docker/96-app-admin.yml`(启动 UI + API;共享数据库与 app-auth/app-cloud 仍由主栈提供);浏览器经 SSH 隧道 `ssh -L 3001:127.0.0.1:3001 `。 -- k8s:`deploy/k8s/30-apps/app-admin.yaml`(ClusterIP,无 Ingress;访问用 `kubectl -n mind-base port-forward svc/app-admin 8016:8016`)。 -- 镜像 `atoncooper/mind-base-app-admin`;CI `.github/workflows/ci-app-admin.yml`(gofmt/vet/test/build)+ docker-build 矩阵。 +- 两机 compose:`deploy/docker/96-app-admin.yml`(启动 UI + API;共享数据库与 app-auth/app-cloud 仍由主栈提供);浏览器经 SSH 隧道 `ssh -L 3001:127.0.0.1:3001 `。支付运营读 `25-app-pay-mysql.yml` 起的库,退款复核需 `.env` 的 `PAY_OPS_KEY`。原 `95-app-pay-admin.yml` 已删除。 +- k8s:`deploy/k8s/30-apps/app-admin.yaml`(ClusterIP,无 Ingress;`kubectl -n mind-base port-forward svc/app-admin 8016:8016`);支付库地址由 `mindbase-defaults` 的 `APPADMIN__PAY_RDBMS__URL` 注入,ops key 取 `mindbase-env` 的 `PAY_OPS_KEY`(可选)。原 `app-pay-admin.yaml` 已删除。 +- swarm:`deploy/swarm/docker-stack.yml` 的 `app-admin-api` + `app-admin`(UI 发布 3001,需防火墙限源或改 SSH 隧道);原 `app-pay-admin` 服务已移除。 +- 镜像 `atoncooper/mind-base-app-admin` / `atoncooper/mind-base-app-admin-ui`;CI `.github/workflows/ci-app-admin.yml`(gofmt/vet/test/build)+ docker-build 矩阵。原 `ci-pay-admin.yml` 已删除。 -## 测试 +## 从 app-pay-admin 迁移(2026-11-03) -```bash -cd app-go/app-admin && go test ./... # 纯 httptest/内存,无需真实 MySQL -cd frontend-admin && pnpm build # tsc --noEmit + vite build -``` - -覆盖:配置覆盖优先级、迁移守护(只建三张自有表)、登录/限流/会话、角色闸门、分页参数校验、审核 reason 必填、审计写入、CORS/安全头、内容预览审计与非分享范围 404 语义。 - -> 后端架构与红线见根 `AGENTS.md` §2.21(本地开发规范,不入 git);完整契约与验收清单见本文各节的测试覆盖说明。 +| 旧(app-pay-admin) | 新(app-admin) | +|---|---| +| 独立进程 :8003 + profile `pay-admin` + SSR WebUI + 可选 TLS/自签证书 | 并入 :8016 API + :3001 SPA(回环,前置入口负责 TLS);8003 / profile / TLS / certgen 全部退役 | +| `payadmin_user` / `payadmin_session` | 统一 `admin_user` / `admin_session`(**不迁移旧账户**,在「账户」页重建) | +| `PAYADMIN__*` 环境变量 | `APPADMIN__PAY_*` / `APPADMIN__PAY__*`(见「配置」) | +| 可选 master API token(`PAYADMIN__WEBUI__TOKEN`,X-WebUI-Token/Bearer) | 退役:统一走 `admin_session` 登录会话(脚本先 `POST /api/login` 再用返回的 cookie) | +| `logs/pay-admin-audit.jsonl` | `logs/pay-audit.jsonl`(与 app-pay 资金审计同名,哈希链) | +| SSR 页面 `/orders` `/memberships` `/grants` `/users` | SPA `/pay/orders` `/pay/memberships` `/pay/grants` `/accounts` | +| `/api/orders` `/api/grants` `/api/users`(payadmin) | `/api/pay/orders` `/api/pay/grants` `/api/console/users` | +| grant 日额度 `PAYADMIN__GRANT__DAILY_MAX_*` | `pay.grant.daily_max_count` / `daily_max_days`(同名语义) | ## 沙箱管理 -> 在分享审核之外,app-admin 兼作**沙箱控制层(app-sandbox)的管理面板**。app-admin 不直连 sandbox_* 表:所有数据经 app-sandbox 的 `/internal/admin/*` 内部 API 获取,沙箱状态以控制层为唯一真相。 +> app-admin 兼作**沙箱控制层(app-sandbox)的管理面板**。app-admin 不直连 sandbox_* 表:所有数据经 app-sandbox 的 `/internal/admin/*` 内部 API 获取,沙箱状态以控制层为唯一真相。 **角色**:`viewer` 只读(页面按钮禁用);`admin` 可执行「取消执行」「强制回收租约」。真正的门禁在 app-admin `requireAdmin()`(403 兜底)。 @@ -147,7 +265,7 @@ cd frontend-admin && pnpm build # tsc --noEmit + vite build | GET | `/api/sandbox/audit` | viewer+ | `GET /internal/admin/audit` | | GET | `/api/sandbox/config` | viewer+ | `GET /internal/admin/config` | -**错误映射**:未配置 → 503 `{detail:"sandbox 管理未配置"}`;上游 4xx → 原状态 + detail;上游 5xx/不可达/非法 JSON → 502 `{detail:"sandbox 服务不可用"}`。上游 401/403 收敛为 502(避免管理端 SPA 误判登录过期)。写操作成功后写 app-admin 审计(`sandbox.execution.cancel` / `sandbox.lease.release`),审计失败则整次操作返回 500(前端可刷新确认真实状态)。 +**错误映射**:未配置 → 503 `{detail:"sandbox 管理未配置"}`;上游 4xx → 原状态 + detail;上游 5xx/不可达/非法 JSON → 502 `{detail:"sandbox 服务不可用"}`。写操作成功后写 app-admin 审计(`sandbox.execution.cancel` / `sandbox.lease.release`),审计失败则整次操作返回 500。 **配置**(两边同时设置才启用;半配置自动清空并告警): @@ -159,7 +277,17 @@ APPADMIN__SANDBOX_ADMIN__BASE_URL=http://app-sandbox:8017 APPADMIN__SANDBOX_ADMIN__TOKEN=<与 SANDBOX__ADMIN__TOKEN 相同> ``` -**页面(frontend-admin `/sandbox`)**:概览(租约/执行状态、24h 失败、运行时配置摘要);执行(uid/项目/状态/时间过滤 + 分页表格 + 详情 Drawer:stdout/stderr 截断预览、产物元数据、审计时间线 + admin 取消执行);租约(过滤 + admin 强制回收);配额(uid/日期 → 档位与 4 个 kind 用量/限额);审计(uid/action/时间过滤)。 +**页面(frontend-admin `/sandbox`)**:概览(租约/执行状态、24h 失败、运行时配置摘要);执行(过滤 + 分页表格 + 详情 Drawer:stdout/stderr 截断预览、产物元数据、审计时间线 + admin 取消执行);租约(过滤 + admin 强制回收);配额(uid/日期 → 档位与 4 个 kind 用量/限额);审计(uid/action/时间过滤)。 **安全**:不展示代码正文与完整 stdout/stderr(仅控制层 `previewBytes` 截断预览);产物只给元数据(name/size/sha256/type),不展示 objectKey、不提供下载;`/config` 不返回任何密钥;写操作双审计(app-admin 操作审计 + app-sandbox `sandbox_audit`,actor=admin,request_id 贯通)。 +## 测试 + +```bash +cd app-go/app-admin && go test ./... # 纯 httptest/内存(含 sqlite),无需真实 MySQL +cd frontend-admin && pnpm build # tsc --noEmit + vite build +``` + +覆盖:配置覆盖优先级、迁移守护(只建三张自有表,禁建 pay_*)、登录/限流/会话、角色闸门、分页参数校验、审核 reason 必填、审计写入、CORS/安全头、内容预览审计与非分享范围 404 语义、支付查询过滤与 DTO(uid string / 金额分)、grant 语义三例(新建/活跃顺延/过期起算)+ 日额度熔断、退款复核代理与未配置 404、控制台账户规则(不能删自己/最后一个 admin)。 + +> 后端架构与红线见根 `AGENTS.md` §2.21(本地开发规范,不入 git);原 `app-pay-admin` 设计文档(`plan/1.0.9-PayAdmin`)与 `docs/app-pay-admin.md` 已随服务退役删除。 diff --git a/docs/app-pay-admin.md b/docs/app-pay-admin.md deleted file mode 100644 index 547fd81e..00000000 --- a/docs/app-pay-admin.md +++ /dev/null @@ -1,86 +0,0 @@ -# app-pay-admin — 支付后台管理 - -> 独立 Go + Gin 后台管理服务:支付域**只读运营查询** + 唯一写操作(会员补偿开通)。全直连 `app_pay` 库,运行时不依赖 app-pay / APISIX。工程惯例对齐 app-task(嵌入式 WebUI、单二进制)。 - -## 定位与红线 - -- **数据访问**:全直连 `app_pay` 库;`pay_*` 表 schema 归 Java(app-pay)所有,**本服务绝不 AutoMigrate**(有守护测试 `TestMigrateOnlyCreatesAdminTables`),唯一自有表是 `payadmin_user`(管理账户) -- **写操作红线**:唯一写操作是会员补偿开通(grant)。禁止在本服务实现退款/关单/SKU 改价等其余资金写操作——需先在 app-pay 侧建端点保证审计一致 -- **端口/暴露**:8003,compose 仅 `127.0.0.1` 回环绑定,**不经 APISIX**,浏览器直连 - -## 补偿开通(grant) - -`POST /api/grants`(admin 角色):`{"uid": >0, "duration_days": 1..3650, "reason": 必填}`。 - -Go 侧**复刻** app-pay `MembershipService.extend` 语义:事务内 `SELECT ... FOR UPDATE` 会员行 → 无记录则新建(tier=VIP)→ 未到期从 `expire_at` 顺延、已过期从 now 起算 → 同事务写 `pay_membership` + `pay_membership_event(type=ADMIN_GRANT)`,`last_order_no` 保留。 - -审计双留痕: - -1. `pay_membership_event.reason` 以 `[操作人]` 前缀记录(Java 表无 operator 列,schema 不漂移) -2. 自有 JSONL `logs/pay-admin-audit.jsonl`(`MEMBERSHIP_EXTENDED`,365 天,lumberjack 轮转)——弥补绕过 Java PayAuditLogger 的缺口 - -## 鉴权 - -| 机制 | 说明 | -|------|------| -| 账户 | `payadmin_user` 表 + bcrypt;种子账户 **`admin` / `app-pay-admin`**(仅当表空时播种,首登后在「账户」页改密) | -| 会话 | 内存 session + **HttpOnly + SameSite=Strict cookie `payadmin_session`**(TLS 下自动 Secure),TTL 默认 12h | -| 角色 | `admin`(补偿开通/账户管理)/ `viewer`(只读);grants 与用户管理仅 admin | -| Master token | `PAYADMIN__WEBUI__TOKEN`(`X-WebUI-Token` 头或 Bearer,等价 admin,常量时间比对) | -| 限流 | 每 IP 每分钟 10 次登录失败 → 429 + `Retry-After` | -| 加固 | `SetTrustedProxies(nil)` 防 XFF 伪造;nosniff / `X-Frame-Options: DENY` / CSP `frame-ancestors 'none'` / `/api/*` no-store;`webui.enabled=false` 整体关闭 | - -## 界面与 API - -WebUI 为 go:embed **html/template 服务端渲染**(无前端框架/构建链;POST-Redirect-GET + `?ok=`/`?err=` flash;MinIO 式顶栏 + 水平 Tab、阿里云式高密度表格)。 - -| 方法 | 路径 | 角色 | 说明 | -|------|------|------|------| -| GET/POST | `/login`、`/logout`(+ `/api/login` `/api/logout`) | — | 登录/登出(SSR 表单流 + JSON API 双载体) | -| GET | `/orders`、`/orders/{order_no}`、`/orders/{order_no}/callbacks`(+ `/api/orders*`) | viewer+ | 订单列表(`uid`/`status`/渠道/订单号/时间范围过滤,默认 `ORDER BY id DESC` 主键序)/ 详情 / 回调流水(脱敏报文 + 验签与处理结论) | -| GET | `/memberships`、`/memberships/{uid}/events`、`/events` | viewer+ | 会员查询(懒过期 active)/ 单人事件流 / 全局事件流(ADMIN_GRANT 审计) | -| GET | `/products` | viewer+ | SKU 目录(只读;改价走 app-pay 运营改库流程) | -| GET/POST | `/grants` | admin | 补偿开通 | -| GET/POST | `/users`、`/users/{id}/password`、`/users/{id}/delete`(+ `/api/users*`) | admin | 控制台账户管理(不能删自己/最后一个 admin) | -| GET | `/health` | — | 探活 | - -JSON API 与 SSR 页面一一对应(`/api/*` 前缀),错误体 `{"detail": ...}`。 - -## 配置 - -三层合并:`default.yaml`(go:embed 嵌入)← `-config` 覆盖层(`config.dev.yaml` 本地 `go run .` 自动探测 / `config.docker.yaml` 镜像内)← `PAYADMIN__` 环境变量(最高)。另经 godotenv 读项目根 `.env`。`config.Validate` fail-loud:`rdbms.url` 必填、cert/key 必须成对。 - -```bash -PAYADMIN__RDBMS__URL=mysql+aiomysql://app_pay:app-pay@app-pay-mysql:3306/app_pay -PAYADMIN__WEBUI__TOKEN=... # 可选 master token -PAYADMIN__WEBUI__SESSION_TTL_MINUTES=720 -PAYADMIN__SERVER__TLS__ENABLED=... # 见下 -PAYADMIN__SERVER__TLS__CERT / _KEY / _CERT_DIR -PAYADMIN__SECURITY__CORS__ALLOW_ORIGINS=... # 逗号分隔,仅跨域调 /api/* 需要 -PAYADMIN__TIMEZONE=Asia/Shanghai # 与 app-pay JVM 对齐(DSN loc=Local) -PAYADMIN__AUDIT__FILE=/app/logs/pay-admin-audit.jsonl -``` - -**TLS(可选)**:默认关(HTTP 回环)。`PAYADMIN__SERVER__TLS__ENABLED=true` 后两种模式——cert/key 成对提供 = 显式文件(变更自动重载、临期告警);**双双留空 = 自动生成自签开发证书**(Go 原生 ECDSA P-256,825 天,重启复用),Rotator 每日巡检:余量 <30 天自动重生成并热切换、文件删除自愈。可选 mini-CA 无告警流程:`scripts/gen-dev-cert.sh|.ps1`。TLS 下 cookie 自动 Secure。 - -## 启动 - -```bash -# Docker(profile pay-admin;depends_on app-pay-mysql) -docker compose --profile pay-admin up -d --build app-pay-admin - -# 本地(模块模式下 go run,自动加载 config.dev.yaml) -cd app-pay-admin && go run . -``` - -打开 `http://127.0.0.1:8003`,默认账户 `admin` / `app-pay-admin`,首登改密。 - -## 测试 - -```bash -cd app-pay-admin && go test ./... # 31 个测试,内存 SQLite + httptest,无需真实 MySQL -``` - -覆盖:API 过滤与权限、grant 语义三例(新建/活跃顺延/过期起算)、SSR 登录流与页面门禁、master token、登录限流、安全响应头、角色闸门、配置覆盖优先级、迁移守护、证书轮换。 - -详细设计:`plan/1.0.9-PayAdmin/1.0.9-PayAdmin.md`(本地文档,不入 git)。 diff --git a/docs/app-pay.md b/docs/app-pay.md index c783c7ae..107caa94 100644 --- a/docs/app-pay.md +++ b/docs/app-pay.md @@ -138,7 +138,7 @@ cd app-pay && mvn test # 20 个测试类 / 86 个用例,纯 JUnit 无外部 - **审计哈希链**:`pay-audit.jsonl` 每行携带 `chain/prev/hash`(SHA-256 链),启动从磁盘最后一条续链,跨重启不断链; 改动历史行会破坏后续全部可验证性。多实例分叉按 chain id 分组校验(单实例为完整链)。 -- **买家信息掩码**:`OrderView` 的 `buyerLogonId/buyerUserId` 统一掩码;DB 与 app-pay-admin(直连库)保留全值。 +- **买家信息掩码**:`OrderView` 的 `buyerLogonId/buyerUserId` 统一掩码;DB 与 app-admin(第二连接直连库,运营查询)保留全值。 - **支付频次风控**:`pay.risk.max-paid-orders-per-day / max-paid-amount-per-day-cents`(滚动 24h,下单时拦截, 429 `RISK_VELOCITY_EXCEEDED` + `RISK_VELOCITY_BLOCKED` 审计)。 - **CI 漏洞门禁**:`ci-app-pay.yml` 增加 Trivy 依赖扫描(HIGH/CRITICAL 红);`.github/dependabot.yml` 每周自动升级 PR。 @@ -167,19 +167,21 @@ cd app-pay && mvn test # 20 个测试类 / 86 个用例,纯 JUnit 无外部 - **告警与压测**:`ops/archive/app-pay/monitoring/app-pay-alerts.rules.yml`(对账差异/回调拒绝/渠道 QPS/风控激增/Hikari 饱和)+ `ops/archive/app-pay/loadtest/app-pay-loadtest.md` 压测基线手册(/test/pay/burst 幂等与吞吐场景)。 -## pay-admin 退款复核工作台(2026-10) +## 退款复核工作台(2026-10 上线;2026-11-03 并入 app-admin) -- **退款审批工作台**:pay-admin 新增"退款复核"页(admin only)——待审列表直读 `pay_refund_approval`, - approve/reject 经 APISIX(pay-ops consumer,`PAY_OPS_KEY`)调 app-pay 的 internal 端点执行; - **operator = 控制台登录账号(服务端注入)**,替代 curl 自报身份;maker != checker 由 app-pay 强制。 - `PAYADMIN__APP_PAY__OPS_KEY` 未配置时整组 404 隐藏;密钥只注入 pay-admin 容器(compose fail-closed)。 +- **退款审批工作台**:统一管理台 app-admin 的「支付运营 → 退款复核」页(admin only)——待审列表直读 + `pay_refund_approval`,approve/reject 经 APISIX(pay-ops consumer,`PAY_OPS_KEY`)调 app-pay 的 + internal 端点执行;**operator = 控制台登录账号(服务端注入)**,替代 curl 自报身份;maker != checker + 由 app-pay 强制。`APPADMIN__PAY__OPS__KEY` 未配置时整组 404 隐藏;密钥只注入 app-admin-api 容器。 + 原独立服务 app-pay-admin(:8003 / profile pay-admin / SSR WebUI / 自带账户)已退役, + 详见 [app-admin.md](app-admin.md)。 - **REFUND_HOLD 冻结单**:订单筛选/状态映射补齐(红色标记),冻结单经退款复核流人工处理。 -- **Grant 额度对齐**:pay-admin 直写 DB 原本绕过 app-pay 的日额度执行点——Grant 现在执行同一熔断 - (`PAYADMIN__GRANT__DAILY_MAX_COUNT=50` / `DAILY_MAX_DAYS=1000`,按 (type,created_at) 聚合),超限 429 + 审计; +- **Grant 额度对齐**:app-admin 直写 DB 原本绕过 app-pay 的日额度执行点——Grant 现在执行同一熔断 + (`pay.grant.daily_max_count=50` / `daily_max_days=1000`,按 (type,created_at) 聚合),超限 429 + 审计; 统计查询失败 fail-open(不阻塞资金写,留 WARN)。 -- **网络安全**:审计 JSONL 哈希链(chain/prev/hash,跨重启续链)对齐 app-pay;安全响应头 - (nosniff/frame-ancestors 'none'/no-referrer)与 cookie SameSite=Strict+HttpOnly 为既有实现(本次核对确认)。 -- **明确不做**:退款/关单/SKU 改价的直接 DB 写(红线不变);会话存储仍是内存态(重启即下线,控制台规模可接受)。 +- **网络安全**:审计 JSONL 哈希链(chain/prev/hash,跨重启续链)对齐 app-pay(app-admin 侧落 + `logs/pay-audit.jsonl`);管理台统一 cookie SameSite=Lax + HttpOnly(TLS 下 Secure)+ 安全响应头。 +- **明确不做**:退款/关单/SKU 改价的直接 DB 写(红线不变,退款只经 internal 端点)。 ## 升级换购(同档补差价,2026-10) diff --git a/docs/architecture.md b/docs/architecture.md index 896b76eb..772a892f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,9 +62,8 @@ APISIX :9080(容器内;鉴权 + 路由 + 限流) | app-task | Go + Gin(独立仓库 [atoncooper/app-task](https://github.com/atoncooper/app-task),镜像 `atoncooper/app-task`) | 8001 | `${APP_TASK_PORT:-8001}` | 默认(task) | 定时任务执行器 + 邮件投递 + WebUI | | app-pay | Java 21 / Spring Boot 3(`app-pay/`) | 8002 | 127.0.0.1:8002 | pay | 交易/会员(HTTPS-only) | | app-pay-test | 同 app-pay 镜像 | 8002 | 127.0.0.1:18002 | pay-test | 测试实例(强制 MOCK 渠道,整库隔离) | -| app-pay-admin | Go + Gin(`app-pay-admin/`) | 8003 | 127.0.0.1:8003 | pay-admin | 支付后台管理控制台(不经 APISIX) | | app-admin | Nginx(静态 SPA;配置 `nginx/app-admin.conf`) | 80 | 127.0.0.1:3001 | 默认 / admin | 分享审核 UI;同源 `/api` 代理至 app-admin-api | -| app-admin-api | Go + Gin(`app-go/app-admin/`) | 8016 | 127.0.0.1:8016 | 默认 / admin | 直连 MySQL;只读用户/分享元数据 + 审核 4 列;代理沙箱管理面;不经 APISIX(详见 [app-admin.md](app-admin.md)) | +| app-admin-api | Go + Gin(`app-go/app-admin/`) | 8016 | 127.0.0.1:8016 | 默认 / admin | 直连共享 MySQL(只读用户/分享元数据 + 审核 4 列)+ 第二连接 app_pay(支付运营只读 + 补偿开通/退款复核);代理沙箱管理面;不经 APISIX(详见 [app-admin.md](app-admin.md)) | | app-sandbox | Go + Gin(`app-go/app-sandbox/`) | 8017 | 127.0.0.1:${APP_SANDBOX_PORT:-8017} | sandbox / full | 沙箱控制层:租约复用、档位配额、审计、产物、准入与 sweeper;app-ts 经 APISIX `sandbox_internal` 调用,管理面仅 app-admin 可调 | | opensandbox-server | OpenSandbox lifecycle server(Docker runtime) | 8080 | 127.0.0.1:${OPEN_SANDBOX_PORT:-8080} | sandbox / full | 负责沙箱创建/执行/删除,挂载 Docker socket(单机可信环境);生产 K8s 见 [deploy/k8s/40-sandbox](../deploy/k8s/40-sandbox/README.md) | | higress | Higress all-in-one | 8080 gw / 18081 console | 127.0.0.1:18080 / :18081 | ai | AI 网关:平台 LLM/embedding/rerank/ASR 出网统一入口 + 计量 | @@ -91,7 +90,6 @@ profile 汇总: | `--profile ai` | + higress(AI 出网网关;平台 AI 流量强依赖,生产必开) | | `--profile pay` | + app-pay | | `--profile pay-test` | + app-pay-test(测试实例) | -| `--profile pay-admin` | + app-pay-admin | | `--profile admin` | + app-admin(默认 profile 已含) | | `--profile full` | 全部以上(含 tools 管理界面) | | `--profile tools` | redis-commander / mongo-express | @@ -161,7 +159,7 @@ forward-auth 统一调 app-auth 的 `POST /internal/auth/verify` 宽验证 `Auth | 文件(云盘/技能 zip/壁纸) | MinIO | 预签名 URL 经 `/minio-proxy/` 同源访问 | | 限流/验证码/quiz quota/分享缓存/事件桥(`bili:page_ready`) | Redis | app-auth / app-quiz / app-ts;app-board 用 db 2 | | 定时任务定义/日志/邮件队列/WebUI 账户 | MySQL `app_task` | app-task 独有 | -| 订单/会员/支付事件/回调流水/SKU/job lock | MySQL `app_pay`(Flyway 迁移) | app-pay 独有;pay-admin 只读共享 + 补偿开通 | +| 订单/会员/支付事件/回调流水/SKU/job lock | MySQL `app_pay`(Flyway 迁移) | app-pay 独有;app-admin 经第二连接只读共享 + 补偿开通(唯一资金写),退款复核经 app-pay internal 端点 | | 桌面端全部数据 | 本机 SQLite | 独立仓库 [mind-base-desktop](https://github.com/atoncooper/mind-base-desktop) 完全本地,不经网络后端 | ## 配置体系 @@ -174,8 +172,7 @@ forward-auth 统一调 app-auth 的 `POST /internal/auth/verify` 宽验证 `Auth | app-go 各服务 | `internal/config/default.yaml`(embed)← env 覆盖 | `APPAUTH__` / `APPCLOUD__` / `APPNOTE__` / `APPBILI__` / `APPQUIZ__` / `APPKG__` | AGENTS.md §2.13-2.19 | | app-task | 独立仓库 `default.yaml`(嵌入镜像) | `APPTASK__` | [app-task.md](app-task.md) | | app-pay | `application.yaml` + docker/test profile(Flyway 管 schema) | `PAY_*` / `ALIPAY_*` | [app-pay.md](app-pay.md) | -| app-pay-admin | `default.yaml`(嵌入)+ config 覆盖层 | `PAYADMIN__` | [app-pay-admin.md](app-pay-admin.md) | -| app-admin | `default.yaml`(嵌入)+ config 覆盖层 | `APPADMIN__` | [app-admin.md](app-admin.md) | +| app-admin | `default.yaml`(嵌入)+ config 覆盖层 | `APPADMIN__`(含 `APPADMIN__PAY_*` / `APPADMIN__PAY__*`) | [app-admin.md](app-admin.md) | | app-sandbox | `default.yaml`(嵌入)+ config 覆盖层 | `SANDBOX__`(DB 走共享 `RDBMS__URL` 或 `SANDBOX__DATABASE__DSN`) | [features.md](features.md) §5 / [academic-agent.md](academic-agent.md) §沙箱 | | app-board / 三个 MCP | `default.yaml`(嵌入) | `APPBOARD__` 等 | [app-board.md](app-board.md) / [app-board-mcp.md](app-board-mcp.md) | @@ -205,7 +202,6 @@ src/ | ci-app-ts | `app-ts/**` | typecheck + vitest | | ci-app-bili / ci-app-note / ci-app-quiz / ci-app-settings / ci-app-kg | 对应 Go 服务 | gofmt / vet / test / build(app-cloud 与 app-sandbox 目前无独立 workflow,依赖本地构建与 compose 验证) | | ci-app-pay | `app-pay/**` | mvn test(纯 JUnit,无外部 DB) | -| ci-pay-admin | app-pay-admin | gofmt / vet / test / build | | ci-app-admin | app-go/app-admin | gofmt / vet / test / build | | ci-frontend-admin | frontend-admin | tsc --noEmit + vite build | -| docker-build | push main / tag | 构建并发布 19 个镜像到 Docker Hub(`atoncooper/mind-base-*`:frontend/app-auth/app-cloud/app-note/app-bili/app-quiz/app-kg/app-settings/app-ts/app-board/app-board-mcp/app-note-mcp/app-quiz-mcp/certgen/minio/app-pay-admin/app-admin/app-admin-ui/app-pay;需仓库 secrets `DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN`;app-sandbox 尚未纳入) | +| docker-build | push main / tag | 构建并发布 18 个镜像到 Docker Hub(`atoncooper/mind-base-*`:frontend/app-auth/app-cloud/app-note/app-bili/app-quiz/app-kg/app-settings/app-ts/app-board/app-board-mcp/app-note-mcp/app-quiz-mcp/certgen/minio/app-admin/app-admin-ui/app-pay;需仓库 secrets `DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN`;app-sandbox 尚未纳入) | diff --git a/docs/configuration.md b/docs/configuration.md index 8154eec1..96b0798e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,8 +24,7 @@ | app-board / 三个 MCP | 各自 `default.yaml`(embed) | `APPBOARD__`/`BOARDMCP__`/`NOTEMCP__` 等 | `RDBMS__URL`/`MONGO__URI`/`REDIS__URL` | | app-task | 独立仓库 default.yaml(embed;本仓只放密钥) | `APPTASK__` | `APPTASK__RDBMS__URL`/`APPTASK__EMAIL__API_KEY` | | app-pay | `application.yaml` + docker/test profile 变体(Flyway 管 schema) | 经 `${VAR:默认}` 占位符 | `PAY_MYSQL_*`/`ALIPAY_*`/`PAY_TLS_*` | -| app-pay-admin | `default.yaml`(embed)+ `-config` 覆盖层 | `PAYADMIN__` | `PAYADMIN__RDBMS__URL`/`PAYADMIN__WEBUI__TOKEN` | -| app-admin | `app-go/app-admin/default.yaml`(embed)+ `-config` 覆盖层 | `APPADMIN__` | `APPADMIN__RDBMS__URL`;`APPADMIN__SEED__USERNAME`/`APPADMIN__SEED__PASSWORD`(种子账户);`APPADMIN__SESSION__TTL_MINUTES` | +| app-admin | `app-go/app-admin/default.yaml`(embed)+ `-config` 覆盖层 | `APPADMIN__` | `APPADMIN__RDBMS__URL`;`APPADMIN__SEED__USERNAME`/`APPADMIN__SEED__PASSWORD`(种子账户);`APPADMIN__SESSION__TTL_MINUTES`;支付运营 `APPADMIN__PAY_RDBMS__URL`/`APPADMIN__PAY__OPS__KEY`/`APPADMIN__PAY__AUDIT_FILE` | | frontend | 构建期 `NEXT_PUBLIC_API_URL`/`NEXT_PUBLIC_APISIX_HOST`(compose 插值);运行时 `SHARE_DOMAIN`/`SHARE_PUBLIC_IP`(分享域名链,见 [sharing.md](sharing.md)) | — | — | 改某个服务的配置 = 改它的 YAML 默认值(committed)或在 `.env` / compose `environment:` 覆盖(敏感值)。**新增配置项必须同步该服务自己的 default.yaml**;仅密钥类才进 `.env.example`。 @@ -35,7 +34,7 @@ | 基础设施 | 消费方 | 连接来源 | |------|------|------| | MySQL `mind_base` | app-go 八服务 + app-board | `RDBMS__URL`(或 `APP<域>__RDBMS__URL`) | -| MySQL `app_task` / `app_pay` | app-task / app-pay | 各自专属连接串 | +| MySQL `app_task` / `app_pay` | app-task / app-pay;app-admin 经第二连接只读 `app_pay`(+ 补偿开通) | 各自专属连接串(app-admin:`APPADMIN__PAY_RDBMS__URL`) | | Mongo `mind_base`(Go 共享)+ `mind_base_ts`(app-ts 自有) | Go 服务 / app-ts | `MONGO__URI`(app-ts 以库区分,跨库只读 `mind_base.asr_documents`) | | Redis | app-auth(限流/验证码)、app-quiz(quota/分享缓存)、app-ts(事件桥)、app-board(缓存 db 2) | `REDIS__URL` | | Milvus | app-ts(`mind_base_ts`:bilibili_videos/cloud_drive/kg_entities) | app-ts config(`MILVUS__*`) | diff --git a/docs/deployment.md b/docs/deployment.md index ac8ce867..b1a55b64 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -23,7 +23,7 @@ | 9080 | APISIX | 无宿主端口 | 仅容器网络;调试 `docker compose exec apisix` | | 8000 / 8001-8016 | app-ts、app-go 各服务、app-task | 127.0.0.1 | 回环调试口,对外走网关 | | 3001 / 8016 | app-admin UI / API | 127.0.0.1 | 管理台仅回环,远程用 SSH 隧道 | -| 8002 / 8003 | app-pay / app-pay-admin | 127.0.0.1 | HTTPS-only;管理台仅回环 | +| 8002 | app-pay | 127.0.0.1 | HTTPS-only | | 3306 / 27017 / 6379 / 19530 / 7474 / 7687 / 9000-9001 | MySQL/Mongo/Redis/Milvus/Neo4j/MinIO | **compose 默认 0.0.0.0** | 生产必须防火墙限制或改回环绑定 | | 18080 / 18443 / 18081 | Higress 网关 / 控制台 | 网关回环;控制台 0.0.0.0 | 控制台生产限来源 IP | @@ -64,7 +64,7 @@ chmod 600 secrets/api_key_encryption_key | `PAY_MYSQL_ROOT_PASSWORD` / `PAY_MYSQL_USER` / `PAY_MYSQL_PASSWORD` | 启用支付时 | app-pay 独立库 `app_pay` | | `PAY_TLS_CERT` / `PAY_TLS_KEY`(`PAY_TLS_ENABLED`) | 生产必填 | 正式证书;默认 `certs/pay.crt` 与 `certs/pay.key`;`false` 仅本机排障 | | `PAY_EXECUTOR_TOKEN`、`PAY_READONLY_KEY` / `PAY_OPS_KEY`、`ALIPAY_*` | 按功能 | 委托令牌、内部 key 分离、支付宝凭证 | -| `PAY_OPS_KEY` | 启用 pay-admin 时硬必填 | compose 对 `PAYADMIN__APP_PAY__OPS_KEY` 做 `:?` 校验 | +| `PAY_OPS_KEY` | 启用退款复核时可选 | app-admin 退款复核经 APISIX pay-ops consumer 调 app-pay internal 端点;留空 = 复核页 404 隐藏 | | `APP_CLOUD_ATTEST_SECRET` | 多副本时必填 | 分享设备证明 HMAC,各副本同值(`openssl rand -hex 32`) | | `APP_CLOUD_ADMIN_REVIEW_TOKEN` | 启用审核预览时 | app-admin ↔ app-cloud 共享令牌,两边同值 | | `APP_KG_INTERNAL_TOKEN`、`SHARE_DOMAIN`、`OCR__API_KEY`、LangSmith 三项 | 可选 | 内网 token、分享域名、OCR/tracing | @@ -80,11 +80,10 @@ chmod 600 secrets/api_key_encryption_key | `docker compose up -d --build` | 默认全栈(实测 29 个服务):frontend/nginx/APISIX/app-ts/app-go 各服务/app-board(+MCP)/app-task/app-pay/app-admin/存储/cert-gen | 首次必须 `--build` | | `--profile ai` | 追加 Higress AI 网关(追加后用于平台 LLM/embedding 出网) | 单独执行只起 5 个服务(higress+mysql/mongo/redis+cert-gen),**不含**主栈 | | `--profile sandbox` | 追加 opensandbox-server / app-sandbox | 单独执行只起 6 个服务;配合 `SANDBOX_ENABLED=true` | -| `--profile pay-admin` | 追加 app-pay-admin | 单独执行只起 6 个服务;需 `PAY_OPS_KEY` | | `--profile pay-test` | 追加 app-pay-test(回环 18002)与测试库(回环 18306) | 仅测试,不用于生产 | | `--profile tools` | 追加 redis-commander / mongo-express | 仅内网调试 | | `--profile pay` / `--profile admin` | 默认集合已含 app-pay / app-admin;仅单域最小栈时显式指定 | 单独执行会排除默认全栈 | -| `--profile full` | 一次启动全量(实测 37 个服务):默认全栈 + higress + sandbox 两件 + pay-admin + pay-test(+库) + tools | 功能最全的一种启动方式 | +| `--profile full` | 一次启动全量(实测 36 个服务):默认全栈 + higress + sandbox 两件 + pay-test(+库) + tools | 功能最全的一种启动方式(管理台默认已含支付运营) | > **compose profile 语义(实测 `docker compose config --services`)**:服务带 `profiles: [""]` 属于默认集合; > **一旦传入 `--profile X`,默认集合不再自动启动**。因此上表「追加」应分两步: @@ -121,7 +120,7 @@ chmod 600 secrets/api_key_encryption_key | app-board / 三个 MCP | `:8004/8005/8009/8012/health` | app-board 为 HTTPS | | app-task | `:8001/health` | | | app-pay | `:8002/health`(HTTPS,真实 DB 探测)+ `/actuator/{health,metrics,prometheus}` | | -| app-pay-admin / app-admin-api | `:8003/health`、`:8016/health` | app-admin DB 不可用返回 503 | +| app-admin-api | `:8016/health` | 主库不可用 503;支付库配置了但不可用 → 503 `pay database unavailable`;否则 `pay:"ok"\|"disabled"` | | app-sandbox | `:8017/health`、`/ready`(MySQL ping)、`/metrics` | | | opensandbox-server | `:8080/health` | | | 基础设施 | compose healthcheck / 依赖链(mysql、mongo、redis、minio、etcd 等) | `docker compose ps` 看 healthy | @@ -129,7 +128,7 @@ chmod 600 secrets/api_key_encryption_key > 入口 nginx 的 `/health` 会转发到 APISIX,而 APISIX 未声明该路由——**不要把入口 `/health` 当健康判据**;用容器 healthcheck、直连回环端口或 `docker compose exec` 检查。 - 日志:`docker compose logs -f `;app-go/app-pay/app-board/MCP 同时写 `/app/logs`(lumberjack 轮转,命名卷 `*_logs`);app-ts 仅 stdout;nginx 在容器 stdout/stderr。 -- 审计:`pay-audit.jsonl`(app-pay 卷 `/app/logs`,365 天哈希链)、`pay-admin-audit.jsonl`(pay-admin 卷)、`admin_audit`(MySQL mind_base,app-admin)、`sandbox_audit`(MySQL mind_base,app-sandbox);学术证据在 Mongo `academic_runs`。 +- 审计:`pay-audit.jsonl`(app-pay 卷 `/app/logs`,365 天哈希链;**app-admin 的资金审计同名同格式,落 `app_admin_logs` 卷**)、`admin_audit`(MySQL mind_base,app-admin 全部后台操作)、`sandbox_audit`(MySQL mind_base,app-sandbox);学术证据在 Mongo `academic_runs`。 - 建议外部监控:入口 `GET /` + 各服务回环 health;Prometheus 抓取 app-cloud/app-sandbox/app-pay 指标;日志集中采集容器 stdout。 ## 7. 备份与恢复 @@ -203,7 +202,7 @@ kubectl -n mind-base get pods -w ``` - 入口是 nginx `Service type=LoadBalancer`(裸集群配 `metallb.yaml`),**无 Ingress 资源**;域名/证书走 `nginx-tls` Secret。 -- 管理台(app-admin/pay-admin/app-sandbox)在 K8s 中通过 `kubectl port-forward` 访问,不对外暴露。 +- 管理台(app-admin,含支付运营/沙箱/账户/审计)与 app-sandbox 在 K8s 中通过 `kubectl port-forward` 访问,不对外暴露。 ## 11. 升级与回滚 diff --git a/docs/faq.md b/docs/faq.md index b9821c66..fb3d0002 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -118,6 +118,10 @@ MySQL 用 `mysqldump`(业务库 `mind_base`、`mind_base_ts` 相关集合在 M 本地回环 `http://127.0.0.1:3001`(Compose `app-admin`),默认 `admin` / `app-admin`,**首次登录强制改密**。生产务必修改默认凭据并限制访问来源。详见 [app-admin.md](app-admin.md)。 +**Q:管理台里支付运营页打不开 / 退款复核不见了?** + +支付运营依赖 `app-pay` 库:未起 `--profile pay`(或 `app-pay-mysql`)时 `/api/pay/*` 返回 503「支付管理未配置」,其余后台功能不受影响。退款复核还需 `.env` 的 `PAY_OPS_KEY`(注入 app-admin-api 的 `APPADMIN__PAY__OPS__KEY`);未配置时该页整组 404 隐藏。详见 [app-admin.md](app-admin.md)。 + **Q:管理台登录 502 / 页面能开但接口失败?** UI 的 `/api` 由 Nginx 同源代理到 app-admin-api;若代理地址被静态缓存(容器重建后 IP 变化),会 502。现配置使用 Docker DNS 动态解析(`set $admin_api ...`),重建后仍异常时确认 `app-admin-api` 容器健康并重启 `app-admin`。 diff --git a/docs/features.md b/docs/features.md index ee0b868b..ef677149 100644 --- a/docs/features.md +++ b/docs/features.md @@ -177,9 +177,9 @@ MindBase 的能力围绕三条主线: - **app-pay**(Java Spring Boot):SKU / 订单 / 支付宝当面付 / 会员态 / 退款逆向,全链路 HTTPS + 资金审计;订单状态机条件更新、回调幂等、多实例 job 租约互斥。 - **档位驱动**:`free / vip / svip` 决定聊天排队权重、各类配额与云盘容量上限;档位不降级(有效期内 SVIP 不被 VIP 覆盖)。 -- **运营后台**:app-pay-admin 只读查询 + 会员补偿开通(`reason` 必填并双留痕)。 +- **运营后台**:统一管理台 app-admin(`http://127.0.0.1:3001`)的「支付运营」:订单/会员/权益事件/SKU 只读查询 + 会员补偿开通(`reason` 必填并三留痕)+ 退款复核(admin only,未配置 ops key 时隐藏)。 -详见 [app-pay.md](app-pay.md) / [app-pay-admin.md](app-pay-admin.md)。 +详见 [app-pay.md](app-pay.md) / [app-admin.md](app-admin.md)。 --- diff --git a/docs/getting-started.md b/docs/getting-started.md index c6045672..e31c6b3b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,7 +2,7 @@ 在本地把 MindBase 跑起来并开始改代码。本文只讲**开发环境**:前置依赖、`.env` 配置、两条启动路径(容器起基础设施 + 本地跑应用 / 全 Docker)、验证链路、测试与常见问题。 -> **技术栈现状**:AI 域在 **app-ts**(Node + Fastify + tsx,端口 8000);业务 CRUD 在 **app-go/** 下的 Go 微服务(auth / cloud / note / bili / quiz / kg / settings / admin)与 **app-board**(Go,HTTPS-only)、**app-pay**(Java 21 / Spring Boot,HTTPS-only)、**app-pay-admin**、**app-task**(独立仓库镜像);沙箱控制层是 **app-go/app-sandbox**;前端是 **frontendv2/**(Next.js 16)与 **frontend-admin/**(Vite + React + antd)。 +> **技术栈现状**:AI 域在 **app-ts**(Node + Fastify + tsx,端口 8000);业务 CRUD 在 **app-go/** 下的 Go 微服务(auth / cloud / note / bili / quiz / kg / settings / admin)与 **app-board**(Go,HTTPS-only)、**app-pay**(Java 21 / Spring Boot,HTTPS-only)、**app-task**(独立仓库镜像);沙箱控制层是 **app-go/app-sandbox**;前端是 **frontendv2/**(Next.js 16)与 **frontend-admin/**(Vite + React + antd)。 > 旧 Python backend(`app/`)已于 2026-10-06 彻底退役,任何 `pip install` / `uvicorn app.main:app` / `app/config/default.yaml` 的说法都已失效。 > > 服务清单、端口、网关路由以 [architecture.md](architecture.md) 为权威;配置体系(各服务 YAML / env 前缀 / 密钥清单)见 [configuration.md](configuration.md);生产部署见 [deployment.md](deployment.md)。 @@ -68,7 +68,7 @@ openssl rand -base64 32 | Out-File -Encoding ascii secrets/api_key_encryption_ke | 变量 | 作用 | |------|------| | `APPTASK_EMAIL_API_KEY` | 定时出题邮件(Resend);为空时邮件 worker 走 dry-run | -| `PAY_OPS_KEY` | `--profile pay-admin` 必需(compose 强制);app-pay-admin 调资金写端点用 | +| `PAY_OPS_KEY` | 可选;app-admin「退款复核」经 APISIX pay-ops consumer 调 app-pay 资金写端点,留空则该页 404 隐藏 | | `SANDBOX_ADMIN_TOKEN` | app-admin 的沙箱管理代理令牌(与 app-sandbox `SANDBOX__ADMIN__TOKEN` 同值) | | `APP_CLOUD_ADMIN_REVIEW_TOKEN` | 管理后台「分享内容受控预览」(app-admin ↔ app-cloud 同值) | | `TAVILY_API_KEY` / `SEARXNG_URL` | `web_search` 的优质搜索源(不配只剩爬虫兜底) | @@ -209,10 +209,9 @@ docker compose ps | 命令 | 实际启动 | |------|----------| | `docker compose up -d` | **29 个服务 = 全栈**:frontend / nginx / apisix / etcd / mysql / mongo / redis / milvus / neo4j / minio / app-ts / app-auth / app-cloud / app-note / app-bili / app-quiz / app-kg / app-settings / app-admin(+api) / app-board(+3 个 MCP) / app-task(+独立 MySQL) / app-pay(+独立 MySQL) | -| `docker compose --profile full up -d` | **37 个服务**:全栈 + higress + app-sandbox + opensandbox-server + app-pay-admin + app-pay-test(+MySQL) + redis-commander / mongo-express | +| `docker compose --profile full up -d` | **36 个服务**:全栈 + higress + app-sandbox + opensandbox-server + app-pay-test(+MySQL) + redis-commander / mongo-express(管理台默认已含支付运营) | | `docker compose --profile ai up -d` | 只有 5 个:higress + mysql/mongo/redis + cert-gen | | `docker compose --profile sandbox up -d` | 只有 6 个:app-sandbox + opensandbox-server + mysql/mongo/redis + cert-gen | -| `docker compose --profile pay-admin up -d` | 只有 6 个:app-pay-admin + app-pay-mysql + mysql/mongo/redis + cert-gen(需要 `PAY_OPS_KEY`) | **结论**:单独传 `--profile X` 会**排除默认全栈**。要在已运行的全栈上追加可选组件,分两步: @@ -230,14 +229,14 @@ docker compose --profile sandbox up -d # 再追加沙箱控制层 |------|------|------| | 用户端(nginx) | http://localhost / https://localhost | 统一入口;443 用开发 CA 自签证书 | | 用户端(frontend 容器直连) | http://localhost:3000 | compose `frontend` 服务 | -| 管理后台 UI | http://127.0.0.1:3001 | app-admin 静态站,同源代理 `/api` 到 8016 | +| 管理后台 UI | http://127.0.0.1:3001 | app-admin 静态站(分享审核 / 用户 / 沙箱 / 支付运营 / 账户 / 审计),同源代理 `/api` 到 8016 | | 管理后台 API | http://127.0.0.1:8016 | 仅回环,不经 APISIX | | app-task 控制台 | http://127.0.0.1:8001/ | 默认账户 `admin` / `app-task-admin`,首登改密 | | Higress 控制台(`--profile ai`) | http://127.0.0.1:18081 | 配置供应商与 consumer key | | MinIO 控制台 | http://127.0.0.1:9001 | 默认 `minioadmin` / `minioadmin` | | Neo4j Browser | http://127.0.0.1:7474 | 密码取 `NEO4J_PASSWORD`(默认 `mind-base`) | -其余服务只在 `127.0.0.1` 暴露调试端口(app-ts 8000、app-auth 8006、app-cloud 8007、app-note 8008、app-bili 8010、app-quiz 8011、app-kg 8013/8014、app-settings 8015、app-sandbox 8017、app-pay 8002 HTTPS、app-pay-admin 8003、MCP 8005/8009/8012);**APISIX 不映射宿主端口**,业务流量一律经 nginx 进入。 +其余服务只在 `127.0.0.1` 暴露调试端口(app-ts 8000、app-auth 8006、app-cloud 8007、app-note 8008、app-bili 8010、app-quiz 8011、app-kg 8013/8014、app-settings 8015、app-sandbox 8017、app-pay 8002 HTTPS、MCP 8005/8009/8012);**APISIX 不映射宿主端口**,业务流量一律经 nginx 进入。 --- @@ -285,7 +284,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost/ # nginx → 前端 **端口被占用。** 常见冲突:3000(compose `frontend` 与本地 `npm run dev` 二选一,或用 `FRONTEND_PORT` 改容器端口)、8000(compose `app-ts` 与本地 dev,可用 `APP_TS_PORT` 改)、80/443(nginx;根 compose 固定映射,Windows 上常被 IIS/其他服务占用,需先停占用进程或改 `docker-compose.yml`;`docker-compose.client.yml` 支持 `NGINX_HTTP_PORT` / `NGINX_HTTPS_PORT`)、3001(compose app-admin UI 与 frontend-admin `pnpm dev`)、8016、3306。先 `docker compose stop <服务>` 再用本地进程。 -**`docker compose` 启动直接报 `err_APISIX_CONSUMER_KEY_not_set_in_.env` / `err_PAY_OPS_KEY_not_set_in_.env`。** 这是 compose 的强制校验,按 §2.3 / §2.4 填 `.env`。 +**`docker compose` 启动直接报 `err_APISIX_CONSUMER_KEY_not_set_in_.env`。** 这是 compose 的强制校验,按 §2.3 / §2.4 填 `.env`。`PAY_OPS_KEY` 自 2026-11-03 起为**可选**(仅 app-admin「退款复核」用;留空则该页 404 隐藏,不再触发 compose 校验)。 **服务起来了但服务间调用全 401/403(APISIX key-auth 拒绝、或下游报 API key 无效)。** `.env` 的 `APISIX_CONSUMER_KEY` 与 `apisix/apisix.yaml` 里 `consumers.internal.key` 不一致(standalone 模式 apisix 不读 env)。两边改成同一个值再重启。 diff --git a/docs/project-structure.md b/docs/project-structure.md index eb316c6e..02c85aea 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -10,12 +10,11 @@ | 目录 / 文件 | 作用 | |-------------|------| | `frontendv2/` | 用户端 Web(Next.js 16 App Router),用户可见的全部页面 | -| `frontend-admin/` | 管理后台 SPA(Vite + React 18 + antd),分享审核 / 用户 / 审计 / 沙箱 | +| `frontend-admin/` | 统一管理后台 SPA(Vite + React 18 + antd),分享审核 / 用户 / 沙箱 / 支付运营 / 账户 / 审计 | | `app-ts/` | AI 域服务(Node 22 + Fastify + tsx):chat / agent / RAG / KG AI / 向量化 / quiz AI / task-quiz / skills / 学术 | | `app-go/` | 各领域 Go 微服务:auth / cloud / note / bili / quiz / kg / settings / admin / sandbox | | `app-board/` | 思维导图 / 白板存储服务(Go + Gin,HTTPS-only) | | `app-pay/` | 交易 / 会员服务(Java 21 + Spring Boot 3 + MyBatis-Plus,HTTPS-only) | -| `app-pay-admin/` | 支付运营后台(Go + Gin,服务端渲染,回环) | | `mcp/` | MCP server(board / note / quiz 工具面),把域能力暴露给 agent | | `apisix/` | APISIX 声明式配置(路由 / 上游 / forward-auth / key-auth / 限流) | | `nginx/` | 统一入口(TLS、静态页、页面与接口分发、缓存) | @@ -144,7 +143,7 @@ frontend-admin/ # 独立 SPA,不复用 frontendv2 代码 | 范围 | 命令 | 说明 | |------|------|------| | app-ts | `npm run typecheck`、`npm test` | vitest 离线;Mongo / Milvus 用例无实例自动 skip | -| Go 服务 | `go test ./...` | 各 `app-go/app-*` 与 `app-board` / `app-pay-admin` / `mcp/*` | +| Go 服务 | `go test ./...` | 各 `app-go/app-*` 与 `app-board` / `mcp/*` | | app-sandbox | `go test ./...`(`tests/` 目录) | black-box;MySQL 用例需 `SANDBOX__TEST_DSN` | | app-pay | `mvn test` | 纯 JUnit,无外部 DB | | frontendv2 | `npm run lint`、`npx tsc --noEmit`、`next build` | CI `ci-frontend` | diff --git a/docs/setup-guide.md b/docs/setup-guide.md index 8b8328f2..d2d9d04a 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -81,7 +81,7 @@ cp .env.example .env # PowerShell: Copy-Item .env.example .env | `SANDBOX_ENABLED=true` | 让 AI 服务真正调用沙箱(默认 `false` 时 `run_code` 会如实提示未配置)。 | | `SANDBOX_ADMIN_TOKEN` | 管理台「沙箱」面板令牌(与 app-sandbox 同值);留空 = 面板禁用,不影响其他后台功能。 | | `APPTASK_EMAIL_API_KEY` | 定时出题邮件(Resend 的 `re_` 开头 key);不填时邮件只记 dry_run,不会真的发送。 | -| `PAY_MYSQL_*` / `PAY_OPS_KEY` | 会员 / 支付:`--profile pay-admin` 必须有 `PAY_OPS_KEY`(compose 强制),前者用本地默认值即可。 | +| `PAY_MYSQL_*` / `PAY_OPS_KEY` | 会员 / 支付:`PAY_MYSQL_*` 用本地默认值即可;`PAY_OPS_KEY` 可选(管理台「退款复核」用,留空则该页隐藏)。 | | `APP_CLOUD_ADMIN_REVIEW_TOKEN` | 管理台「分享内容受控预览」。 | | `TAVILY_API_KEY` / `SEARXNG_URL`、`LANGSMITH_*`、`BOARD_MCP_*` | 可选增强:联网搜索质量、调用链追踪、外部 MCP 宿主接入。 | @@ -89,7 +89,7 @@ cp .env.example .env # PowerShell: Copy-Item .env.example .env ## 4. 启动 -### 4.1 先起主栈(默认即全栈,不含 AI 网关 / 沙箱 / 支付后台) +### 4.1 先起主栈(默认即全栈,不含 AI 网关 / 沙箱;管理台已含支付运营) ```bash docker compose up -d @@ -108,8 +108,7 @@ docker compose ps ```bash docker compose --profile ai up -d # AI 网关(AI 能力必须) docker compose --profile sandbox up -d # 科研代码执行:沙箱控制层 + OpenSandbox -docker compose --profile pay up -d # 会员 / 支付 -docker compose --profile pay-admin up -d # 支付后台(需 PAY_OPS_KEY) +docker compose --profile pay up -d # 会员 / 支付(支付运营在默认管理台内) docker compose --profile tools up -d # Redis / Mongo 网页管理工具 ``` @@ -174,7 +173,7 @@ curl -s http://127.0.0.1:8017/health # 沙箱控制层(需先启 sandbox 4. 「**科研**」:科研工作台(项目六阶段、文献检索与核验、稿件写作)。其中「在沙箱里跑分析脚本」需要 4.2 的 `--profile sandbox` 与 3.4 的 `SANDBOX_ENABLED=true`。 5. 「**题目练习**」生成 / 批改题目;「**定时出题**」设定时间后自动出题并发邮件(需 3.4 的邮件配置)。 6. 其他入口:「更多」→ 知识图谱 / 知识盲区;「笔记」「思维导图」「云盘」「技能」;右上角「设置」改偏好与壁纸。 -7. 管理与运维:管理台(分享审核、沙箱面板);支付后台需 4.2 的 `--profile pay-admin`,浏览器打开 `http://127.0.0.1:8003`(默认 `admin` / `app-pay-admin`,首登改密)。 +7. 管理与运维:管理台 `http://127.0.0.1:3001`(分享审核、沙箱面板、支付运营、账户与审计;默认 `admin` / `app-admin`,首登改密)。支付运营读 `app-pay` 库(`--profile pay`),退款复核需 `PAY_OPS_KEY`。 ## 8. 常见问题 diff --git a/docs/vscode-setup.md b/docs/vscode-setup.md index a3967300..f299c0bc 100644 --- a/docs/vscode-setup.md +++ b/docs/vscode-setup.md @@ -1,7 +1,7 @@ # VS Code 配置说明(多语言单仓) 本仓同时包含 TS/Node(`frontendv2` / `app-ts` / `frontend-admin`)、Go(`app-go/*`、`mcp/*`、 -`app-board`、`app-pay-admin`)、Java(`app-pay`)与脚本工程。 +`app-board`)、Java(`app-pay`)与脚本工程。 > 桌面端(Tauri 2 + Rust)已拆分为独立仓库 [atoncooper/mind-base-desktop](https://github.com/atoncooper/mind-base-desktop), > 本仓不再包含 Rust 工程,相关 rust-analyzer / cargo 任务配置随源码一并迁出。 diff --git a/frontend-admin/README.md b/frontend-admin/README.md index 27810e02..1801afd0 100644 --- a/frontend-admin/README.md +++ b/frontend-admin/README.md @@ -1,7 +1,8 @@ -# frontend-admin — MindBase 管理后台 +# frontend-admin — MindBase 统一管理后台前端 独立管理后台前端(**Vite + React 18 + TypeScript + antd v5 + react-router-dom v6**)。 -唯一契约:`plan/1.0.16-ShareModeration/1.0.16-ShareModeration.md`(§2 选型偏离记录 / §5 API / §7 本前端契约)。 +覆盖:云盘分享审核 + 沙箱控制台 + 支付运营(订单 / 会员 / 权益事件 / SKU / 补偿开通 / 退款复核)+ 控制台账户 + 审计日志。 +唯一后端:`app-go/app-admin`(API)与 `docs/app-admin.md`(使用/API 文档);原独立服务 `app-pay-admin` 已于 2026-11-03 合并退役。 > 与 `frontendv2/`(用户端 Next.js)严格分离:不共享组件、不共享 API 层、不挂 workspace。 @@ -26,11 +27,22 @@ docker compose up -d --build app-admin | `/login` | 登录;`mustChangePassword=true` 时强制先改密(POST /api/me/password) | | `/shares` | 分享审核:筛选(审核状态/uid/关键词/是否已撤销/**内容状态**)+ 元数据 Table + **每行「查看」详情抽屉**(元数据 + 分享内文件列表 + 受控预览入口)+ 放行/封杀/解封(reason 弹窗,封杀必填;admin 角色)。**默认只看目标仍存在的可审核分享**;目标已删除的存量记录仅在「内容:全部/目标已删除」下展示,标记为不可审核 | | `/users` | 用户只读:uid/email/phone/nickname/status/角色 Tag;行点击抽屉看角色明细。查询自动识别 **UID / 11 位手机号 / 邮箱**(前端正则判定并显示模式,后端再校验;不传 mode 后端也会推断) | -| `/audit` | 审计日志倒序;操作下拉筛选 + 管理员筛选;操作中文标签、详情按动作格式化(原因/预览文件与模式/登录 IP 与客户端/改密);「分享 #ID」跳转 `/shares?share=ID` 自动打开详情抽屉 | - -## 红线(契约 §7) - +| `/sandbox` | 沙箱控制面板:概览 / 执行 / 租约 / 配额 / 审计(数据经 app-sandbox `/internal/admin/*`,viewer 只读、admin 可取消执行/回收租约) | +| `/pay/orders`、`/pay/orders/:orderNo` | 支付订单列表(订单号 / UID / 交易号 / 状态 / 渠道 / 创建时间范围)与详情(Descriptions + 渠道回调流水 Table) | +| `/pay/memberships`、`/pay/memberships/:uid/events` | 会员 UID 精确查询 / 全量分页;单人权益事件流 | +| `/pay/events` | 全局权益事件流(uid / type 筛选,ADMIN_GRANT 审计用) | +| `/pay/products` | SKU 商品只读(改价走 app-pay 运营流程) | +| `/pay/grants` | 补偿开通(admin):表单 + 结果 Descriptions + 最近 ADMIN_GRANT;日额度超限 429 透传 | +| `/pay/refunds` | 退款复核(admin):状态筛选 + 通过/驳回;未配置(后端 404)显示未配置提示 | +| `/accounts` | 控制台账户(admin):列表 / 新建 / 重置密码 / 删除(不能删自己/最后一个 admin);viewer 显示 403 且不发请求 | +| `/audit` | 审计日志倒序;操作下拉筛选 + 管理员筛选;操作中文标签、详情按动作格式化(审核/预览/登录改密/补偿开通/退款复核/账户动作);「分享 #ID」跳转 `/shares?share=ID` 自动打开详情抽屉 | + +## 请求层与红线 + +- **唯一请求层** `src/api/client.ts`(cookie 携带、`{detail}` 错误透传、401 清态踢回 `/login`);组件禁止直接 `fetch`。 +- **设备指纹**:`src/device.ts` 在 localStorage 生成并持久化 `X-Device-Id`,所有请求自动携带;后端将会话绑定到设备指纹 + IP,清浏览器存储/换设备/IP 异常需要重新登录。 +- 按域拆分:`src/api/pay.ts`(支付,uid 一律 string、金额整数分)、`src/api/accounts.ts`(控制台账户)、`src/api/types.ts`(分享/用户/审计)。 - 任何页面不出现文件下载/内容导出能力;表格只展示名称/大小等元数据。 - 不直连 MinIO;登录态仅依赖 HttpOnly session cookie,前端不落 token 存储。 -- `{detail}` 错误透传;401 统一清态踢回 `/login`。 -- 受控内容预览(Issue #154):仅 admin 角色 + 分享范围;**审核员可查看任意分享内容**(含撤销/过期/banned/历史未确认《管理员内容审核告知》的分享)。图片/音视频/PDF 经 UI Nginx 同源 `/minio-proxy/` 内联展示(PDF iframe SAMEORIGIN);docx/xlsx/pptx/html/md/txt 由后端提取纯文本展示(xlsx 单元格文本已解码);**无下载按钮、无 URL 复制**;每次预览后端落审计。用户告知状态仅作展示,不阻断预览。 +- 受控内容预览(Issue #154):仅 admin 角色 + 分享范围;**审核员可查看任意分享内容**(含撤销/过期/banned/历史未确认《管理员内容审核告知》的分享)。图片/音视频/PDF 经 UI Nginx 同源 `/minio-proxy/` 内联展示(PDF iframe SAMEORIGIN);docx/xlsx/pptx/html/md/txt 由后端提取纯文本展示;**无下载按钮、无 URL 复制**;每次预览后端落审计。 +- 支付域资金写边界:本前端只调用 app-admin API;补偿开通是唯一可直写 `app_pay` 的操作,退款复核经 app-pay internal 端点执行,前端不做任何本地状态替代。 diff --git a/frontend-admin/src/App.tsx b/frontend-admin/src/App.tsx index dcb9e6a3..9dcdcf3e 100644 --- a/frontend-admin/src/App.tsx +++ b/frontend-admin/src/App.tsx @@ -4,13 +4,22 @@ import { setUnauthorizedHandler } from "./api/client"; import { refreshMe } from "./auth"; import AdminLayout from "./components/AdminLayout"; import RequireAuth from "./components/RequireAuth"; +import Accounts from "./pages/Accounts"; import Audit from "./pages/Audit"; import Login from "./pages/Login"; import Sandbox from "./pages/Sandbox"; import Shares from "./pages/Shares"; import Users from "./pages/Users"; +import Grants from "./pages/pay/Grants"; +import PayEvents from "./pages/pay/Events"; +import PayMemberEvents from "./pages/pay/MemberEvents"; +import PayMemberships from "./pages/pay/Memberships"; +import PayOrderDetail from "./pages/pay/OrderDetail"; +import PayOrders from "./pages/pay/Orders"; +import PayProducts from "./pages/pay/Products"; +import Refunds from "./pages/pay/Refunds"; -/** 401 统一清态踢回 /login(契约 §7 请求层) */ +/** 401 统一清态踢回 /login(请求层) */ function AuthGate() { const navigate = useNavigate(); useEffect(() => { @@ -39,6 +48,17 @@ export default function App() { } /> } /> } /> + {/* 支付运营 */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* 统一控制台账户管理 */} + } /> } /> } /> diff --git a/frontend-admin/src/api/accounts.ts b/frontend-admin/src/api/accounts.ts new file mode 100644 index 00000000..5603583b --- /dev/null +++ b/frontend-admin/src/api/accounts.ts @@ -0,0 +1,47 @@ +// 控制台账户管理(admin;不再依赖支付库,pay 关闭也可用) +// 组件禁止直接 fetch:统一经 client.ts 的 request/post。 +import { post, request } from "./client"; +import type { AdminRole } from "./types"; + +/** GET /api/console/users 的列表项 */ +export interface ConsoleUserItem { + id: number; + username: string; + role: AdminRole; + mustChangePassword: boolean; + lastLoginAt: string | null; + createdAt: string | null; +} + +/** POST /api/console/users 的响应 */ +export interface ConsoleUserCreateResult { + id: number; + username: string; + role: AdminRole; +} + +export interface ConsoleUserCreateBody { + /** 2..64 */ + username: string; + /** 8..128 */ + password: string; + /** 缺省 viewer */ + role?: AdminRole; +} + +export const accountsApi = { + /** 列表(username ASC,后端不分页) */ + list: () => request<{ items: ConsoleUserItem[] }>("/api/console/users"), + + /** 新建账户(409 = 用户名已存在) */ + create: (body: ConsoleUserCreateBody) => + post("/api/console/users", body), + + /** 重置密码:后端置 must_change_password=true 并删除该账户所有会话 */ + resetPassword: (id: number, password: string) => + post<{ ok: boolean }>(`/api/console/users/${id}/password`, { password }), + + /** 删除账户:后端拦截删除自己 / 最后一个 admin(400),并删除其会话 */ + remove: (id: number) => + request<{ ok: boolean }>(`/api/console/users/${id}`, { method: "DELETE" }), +}; diff --git a/frontend-admin/src/api/client.ts b/frontend-admin/src/api/client.ts index f50593ca..565fb187 100644 --- a/frontend-admin/src/api/client.ts +++ b/frontend-admin/src/api/client.ts @@ -10,6 +10,7 @@ import type { UserItem, UserQueryMode, } from "./types"; +import { DEVICE_ID_HEADER, getDeviceId } from "../device"; export class ApiError extends Error { readonly status: number; @@ -30,12 +31,16 @@ export function setUnauthorizedHandler(fn: UnauthorizedHandler | null): void { export async function request(path: string, init: RequestInit = {}): Promise { let resp: Response; try { + // 所有管理端请求都携带稳定的设备 ID,供服务端做设备指纹绑定校验。 + const headers = new Headers(init.headers); + headers.set(DEVICE_ID_HEADER, getDeviceId()); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } resp = await fetch(path, { credentials: "include", ...init, - headers: init.body - ? { "Content-Type": "application/json", ...(init.headers ?? {}) } - : init.headers, + headers, }); } catch { throw new ApiError(0, "网络错误:无法连接管理后端"); @@ -46,7 +51,7 @@ export async function request(path: string, init: RequestInit = {}): Promise< throw new ApiError(401, "登录已失效,请重新登录"); } if (!resp.ok) { - // 契约 §5:错误体 {"detail": ...},透传 message + // 错误体 {"detail": ...},透传 message let detail = `请求失败(HTTP ${resp.status})`; try { const data: unknown = await resp.json(); @@ -85,7 +90,7 @@ export function qs(params: Record return s ? `?${s}` : ""; } -// —— 契约 §5 端点封装(组件禁止直接 fetch) —— +// —— 端点封装(组件禁止直接 fetch) —— export const api = { login: (username: string, password: string) => diff --git a/frontend-admin/src/api/pay.ts b/frontend-admin/src/api/pay.ts new file mode 100644 index 00000000..0c6e9a53 --- /dev/null +++ b/frontend-admin/src/api/pay.ts @@ -0,0 +1,231 @@ +// 支付运营域 API:类型 + 端点封装。 +// 约定:uid 一律以 string 表示(bigint 精度安全,勿做数值运算);金额一律整数分; +// 时间输出 RFC3339(展示统一走 components/format.ts 的 formatTime)。 +import { post, qs, request } from "./client"; +import type { Paged } from "./types"; + +/** GET /api/pay/orders 的列表项(订单快照,金额整数分)。 */ +export interface PayOrderItem { + orderNo: string; + uid: string; + skuCode: string; + productTitle: string; + durationDays: number; + amountCents: number; + channel: string; + status: string; + version: number; + channelTradeNo: string | null; + idempotencyKey: string | null; + failReason: string | null; + expiresAt: string; + paidAt: string | null; + closedAt: string | null; + createdAt: string; + updatedAt: string; +} + +/** 订单渠道回调流水(脱敏报文)。 */ +export interface PayCallbackItem { + orderNo: string; + channel: string; + payloadMasked: string | null; + signatureResult: string; + handleResult: string; + message: string | null; + createdAt: string; +} + +/** 会员态(active = expireAt > now,由后端判定)。 */ +export interface PayMembershipItem { + uid: string; + tier: string; + active: boolean; + expireAt: string; + autoRenew: boolean; + lastOrderNo: string | null; + createdAt: string; + updatedAt: string; +} + +/** 权益变更事件(pay_membership_event)。 */ +export interface PayMembershipEventItem { + id: number; + uid: string; + type: string; + orderNo: string | null; + days: number; + expireBefore: string; + expireAfter: string; + reason: string | null; + createdAt: string; +} + +/** SKU 目录(只读)。 */ +export interface PayProductItem { + code: string; + title: string; + durationDays: number; + priceCents: number; + status: string; + sort: number; + createdAt: string; + updatedAt: string; +} + +/** 补偿开通请求(uid 允许 number|string,后端按 bigint 解析)。 */ +export interface PayGrantRequest { + uid: number | string; + durationDays: number; + reason: string; + tier?: "VIP" | "SVIP"; +} + +/** 补偿开通结果(expireBefore/After 为变更前后到期时间)。 */ +export interface PayGrantResult { + uid: string; + tier: string; + days: number; + expireBefore: string; + expireAfter: string; + created: boolean; + active: boolean; +} + +export type RefundApprovalStatus = "PENDING" | "APPROVED" | "REJECTED" | "ALL"; + +/** 退款双人复核审批单(pay_refund_approval,只读)。 */ +export interface PayRefundApprovalItem { + orderNo: string; + maker: string; + makerReason: string; + status: string; + createdAt: string; + decidedAt: string | null; + checker: string | null; + checkReason: string | null; +} + +/** 退款复核执行结果(资金写经 app-pay internal 端点,成功后回显复核人)。 */ +export interface PayRefundActionResult { + ok: boolean; + orderNo: string; + confirmed?: boolean; + checker?: string; +} + +export type PayOrderQuery = { + uid?: string; + status?: string; + channel?: string; + sku?: string; + orderNo?: string; + tradeNo?: string; + /** YYYY-MM-DD(后端按 Asia/Shanghai 解析)或 RFC3339 */ + createdFrom?: string; + createdTo?: string; + page?: number; + pageSize?: number; +}; + +export type PayEventQuery = { + uid?: string; + type?: string; + page?: number; + pageSize?: number; +}; + +export type PayRefundQuery = { + status?: RefundApprovalStatus; + page?: number; + pageSize?: number; +}; + +const enc = encodeURIComponent; + +/** 支付运营端点封装:组件禁止直接 fetch。 */ +export const payApi = { + /** 订单列表(ORDER BY id DESC;支持订单号/UID/交易号/状态/渠道/创建时间范围) */ + orders: (params: PayOrderQuery) => request>(`/api/pay/orders${qs({ ...params })}`), + + /** 订单详情:orderNo 查不到时后端用 tradeNo 兜底 */ + order: (orderNo: string, tradeNo?: string) => + request<{ order: PayOrderItem }>(`/api/pay/orders/${enc(orderNo)}${qs({ tradeNo })}`), + + /** 订单渠道回调流水 */ + orderCallbacks: (orderNo: string, params: { page?: number; pageSize?: number }) => + request>(`/api/pay/orders/${enc(orderNo)}/callbacks${qs({ ...params })}`), + + /** 单个会员态:无记录返回 { membership: null }(不是 404) */ + membership: (uid: string) => + request<{ membership: PayMembershipItem | null }>(`/api/pay/memberships${qs({ uid })}`), + + /** 会员全量分页列表 */ + memberships: (params: { page?: number; pageSize?: number }) => + request>(`/api/pay/memberships${qs({ ...params })}`), + + /** 单人权益事件流 */ + memberEvents: (uid: string, params: { page?: number; pageSize?: number }) => + request>( + `/api/pay/memberships/${enc(uid)}/events${qs({ ...params })}`, + ), + + /** 全局权益事件流(uid/type 筛选) */ + events: (params: PayEventQuery) => + request>(`/api/pay/events${qs({ ...params })}`), + + /** SKU 目录(sort ASC, id ASC;只读) */ + products: () => request<{ items: PayProductItem[] }>("/api/pay/products"), + + /** 运营补偿开通(admin;日额度超限 429) */ + grant: (body: PayGrantRequest) => post("/api/pay/grants", body), + + /** 退款复核列表(未配置退款复核时后端整组 404) */ + refunds: (params: PayRefundQuery) => + request>(`/api/pay/refunds${qs({ ...params })}`), + + /** 退款复核通过(经 app-pay internal 端点执行,非本控制台直写) */ + approveRefund: (orderNo: string, reason?: string) => + post(`/api/pay/refunds/${enc(orderNo)}/approve`, { + reason: reason?.trim() || undefined, + }), + + /** 退款复核驳回 */ + rejectRefund: (orderNo: string, reason?: string) => + post(`/api/pay/refunds/${enc(orderNo)}/reject`, { + reason: reason?.trim() || undefined, + }), +}; + +// —— 跨任务约定(Lead 冻结):pay-ui-ops 的 Grants.tsx / Refunds.tsx 直接 import 这些命名导出 —— + +/** 补偿开通结果(同 PayGrantResult)。 */ +export type GrantResult = PayGrantResult; + +/** 退款复核审批单(同 PayRefundApprovalItem)。 */ +export type RefundItem = PayRefundApprovalItem; + +/** POST /api/pay/grants —— 运营补偿开通(admin,资金写)。 */ +export function grantMembership(body: PayGrantRequest): Promise { + return payApi.grant(body); +} + +/** GET /api/pay/events —— 全局权益事件流(Grants 页「最近 ADMIN_GRANT」也用这个)。 */ +export function listEvents(params: PayEventQuery): Promise> { + return payApi.events(params); +} + +/** GET /api/pay/refunds —— 退款复核审批单分页列表(未配置时后端 404)。 */ +export function listRefunds(params: PayRefundQuery): Promise> { + return payApi.refunds(params); +} + +/** POST /api/pay/refunds/:orderNo/approve —— 复核通过(body 非空 reason 时才带 reason)。 */ +export function approveRefund(orderNo: string, reason?: string): Promise { + return payApi.approveRefund(orderNo, reason); +} + +/** POST /api/pay/refunds/:orderNo/reject —— 复核驳回(body {})。 */ +export function rejectRefund(orderNo: string): Promise { + return payApi.rejectRefund(orderNo); +} diff --git a/frontend-admin/src/api/sandbox.ts b/frontend-admin/src/api/sandbox.ts index 02b5b894..c8b2705d 100644 --- a/frontend-admin/src/api/sandbox.ts +++ b/frontend-admin/src/api/sandbox.ts @@ -11,7 +11,7 @@ import { post, qs, request } from "./client"; /** uid 运行时可能是 number 或 string(见文件头 bigint 说明)。 */ export type SandboxUid = number | string; -/** 统一分页响应(契约 §三:page 从 1 起、pageSize 1..100,默认 20)。 */ +/** 统一分页响应(page 从 1 起、pageSize 1..100,默认 20)。 */ export interface SandboxPaged { items: T[]; total: number; @@ -109,7 +109,7 @@ export interface SandboxAuditItem { /** * 执行详情 = 列表视图 + 产物元数据 + 审计时间线。 - * 契约 §三只说「预览、产物元数据、审计事件」,未固定后两者字段名:这里同时接受 + * 接口约定只要求「预览、产物元数据、审计事件」,未固定后两者字段名:这里同时接受 * audit / events 两种上游命名,避免与并行落地的 Go handler 命名不一致时页面空白。 */ export interface SandboxExecutionDetail extends SandboxExecutionItem { diff --git a/frontend-admin/src/api/types.ts b/frontend-admin/src/api/types.ts index 78de88b6..74db5de9 100644 --- a/frontend-admin/src/api/types.ts +++ b/frontend-admin/src/api/types.ts @@ -1,4 +1,4 @@ -// 契约 §5:app-admin API 类型定义(frontend-admin 内唯一数据形状来源) +// app-admin API 类型定义(frontend-admin 内唯一数据形状来源) export type AdminRole = "admin" | "viewer"; diff --git a/frontend-admin/src/components/AdminLayout.tsx b/frontend-admin/src/components/AdminLayout.tsx index 5f1f9f7c..3b4cc569 100644 --- a/frontend-admin/src/components/AdminLayout.tsx +++ b/frontend-admin/src/components/AdminLayout.tsx @@ -1,14 +1,22 @@ import { useState } from "react"; import { Dropdown, Layout, Menu, Tag } from "antd"; +import type { MenuProps } from "antd"; import { AuditOutlined, CloudServerOutlined, DownOutlined, - LogoutOutlined, + GiftOutlined, + HistoryOutlined, KeyOutlined, + LogoutOutlined, + PayCircleOutlined, + ProfileOutlined, + RollbackOutlined, ShareAltOutlined, + TagsOutlined, TeamOutlined, UserOutlined, + UserSwitchOutlined, } from "@ant-design/icons"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { App as AntApp } from "antd"; @@ -18,6 +26,30 @@ import ChangePasswordModal from "./ChangePasswordModal"; const ROLE_LABEL: Record = { admin: "管理员", viewer: "只读" }; +/** 支付运营子菜单 key(SubMenu 不可导航,仅作为展开分组) */ +const PAY_MENU_KEY = "pay"; + +/** pathname → 侧栏选中项:先精确匹配,再取最长路径前缀(/pay/orders/:no → /pay/orders)。 */ +function selectedKeyFor(pathname: string): string { + const keys = [ + "/shares", + "/users", + "/sandbox", + "/pay/orders", + "/pay/memberships", + "/pay/events", + "/pay/products", + "/pay/grants", + "/pay/refunds", + "/accounts", + "/audit", + ]; + if (keys.includes(pathname)) return pathname; + const prefixed = keys.filter((k) => pathname.startsWith(k + "/")); + prefixed.sort((a, b) => b.length - a.length); + return prefixed[0] ?? pathname; +} + export default function AdminLayout() { const navigate = useNavigate(); const location = useLocation(); @@ -25,6 +57,8 @@ export default function AdminLayout() { const { modal } = AntApp.useApp(); const [pwdOpen, setPwdOpen] = useState(false); + const isAdmin = me?.role === "admin"; + const logout = () => { modal.confirm({ title: "确认退出登录?", @@ -54,6 +88,34 @@ export default function AdminLayout() { /> ); + // viewer 隐藏补偿开通 / 退款复核 / 账户(写操作或账户管理) + const payChildren: MenuProps["items"] = [ + { key: "/pay/orders", icon: , label: "订单" }, + { key: "/pay/memberships", icon: , label: "会员" }, + { key: "/pay/events", icon: , label: "权益事件" }, + { key: "/pay/products", icon: , label: "SKU" }, + ...(isAdmin + ? [ + { key: "/pay/grants", icon: , label: "补偿开通" }, + { key: "/pay/refunds", icon: , label: "退款复核" }, + ] + : []), + ]; + + const items: MenuProps["items"] = [ + { key: "/shares", icon: , label: "分享审核" }, + { key: "/users", icon: , label: "用户" }, + { key: "/sandbox", icon: , label: "沙箱" }, + { + key: PAY_MENU_KEY, + icon: , + label: "支付运营", + children: payChildren, + }, + ...(isAdmin ? [{ key: "/accounts", icon: , label: "账户" }] : []), + { key: "/audit", icon: , label: "审计日志" }, + ]; + return ( @@ -62,14 +124,13 @@ export default function AdminLayout() { navigate(key)} - items={[ - { key: "/shares", icon: , label: "分享审核" }, - { key: "/users", icon: , label: "用户" }, - { key: "/sandbox", icon: , label: "沙箱" }, - { key: "/audit", icon: , label: "审计日志" }, - ]} + selectedKeys={[selectedKeyFor(location.pathname)]} + defaultOpenKeys={[PAY_MENU_KEY]} + onClick={({ key }) => { + if (key === PAY_MENU_KEY) return; + navigate(key); + }} + items={items} /> diff --git a/frontend-admin/src/components/ChangePasswordModal.tsx b/frontend-admin/src/components/ChangePasswordModal.tsx index d6dd8003..f2489431 100644 --- a/frontend-admin/src/components/ChangePasswordModal.tsx +++ b/frontend-admin/src/components/ChangePasswordModal.tsx @@ -16,7 +16,7 @@ interface FormValues { confirm: string; } -/** 首登强制改密(契约 §3.3 must_change_password → POST /api/me/password) */ +/** 首登强制改密(must_change_password → POST /api/me/password) */ export default function ChangePasswordModal({ mandatory = false, open, onClose }: Props) { const { message } = AntApp.useApp(); const [form] = Form.useForm(); diff --git a/frontend-admin/src/components/format.ts b/frontend-admin/src/components/format.ts index 94eb5b86..42e36d18 100644 --- a/frontend-admin/src/components/format.ts +++ b/frontend-admin/src/components/format.ts @@ -18,3 +18,12 @@ export function formatTime(value: string | null | undefined): string { const d = dayjs(value); return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : value; } + +/** 整数分 → 元(元 = 分/100 整数部分,角分 = 分%100 补零)。 */ +export function formatCents(cents: number | null | undefined): string { + if (cents === null || cents === undefined || !Number.isFinite(cents)) return "-"; + const abs = Math.abs(Math.trunc(cents)); + const yuan = Math.floor(abs / 100); + const fen = abs % 100; + return `${cents < 0 ? "-" : ""}¥${yuan}.${String(fen).padStart(2, "0")}`; +} diff --git a/frontend-admin/src/components/pay-meta.ts b/frontend-admin/src/components/pay-meta.ts new file mode 100644 index 00000000..78a9e814 --- /dev/null +++ b/frontend-admin/src/components/pay-meta.ts @@ -0,0 +1,56 @@ +// 支付运营域的展示元数据(状态/渠道/事件类型的中文标签与颜色)。 +// 只做展示映射,不参与请求;未知值一律原样兜底。 + +export const ORDER_STATUS_META: Record = { + CREATED: { label: "待支付", color: "gold" }, + PAID: { label: "已支付", color: "blue" }, + DELIVERED: { label: "已交付", color: "green" }, + CLOSED: { label: "已关闭", color: "default" }, + REFUNDING: { label: "退款中", color: "orange" }, + REFUNDED: { label: "已退款", color: "red" }, +}; + +export const ORDER_STATUS_OPTIONS = Object.entries(ORDER_STATUS_META).map(([value, meta]) => ({ + value, + label: meta.label, +})); + +export const CHANNEL_LABEL: Record = { + ALIPAY: "支付宝", + WECHAT: "微信", + MOCK: "模拟", +}; + +export const CHANNEL_OPTIONS = Object.entries(CHANNEL_LABEL).map(([value, label]) => ({ + value, + label, +})); + +export const EVENT_TYPE_META: Record = { + ACTIVATE: { label: "开通", color: "green" }, + RENEW: { label: "续费", color: "blue" }, + ADMIN_GRANT: { label: "运营补偿", color: "volcano" }, + REFUND_REVOKE: { label: "退款回收", color: "red" }, + TIER_DOWNGRADE_BLOCKED: { label: "档位降级拦截", color: "orange" }, +}; + +export const EVENT_TYPE_OPTIONS = Object.entries(EVENT_TYPE_META).map(([value, meta]) => ({ + value, + label: meta.label, +})); + +export const TIER_META: Record = { + VIP: { label: "VIP", color: "gold" }, + SVIP: { label: "SVIP", color: "purple" }, +}; + +export const REFUND_STATUS_META: Record = { + PENDING: { label: "待复核", color: "gold" }, + APPROVED: { label: "已通过", color: "green" }, + REJECTED: { label: "已驳回", color: "red" }, +}; + +export const REFUND_STATUS_OPTIONS = [ + { value: "ALL", label: "全部状态" }, + ...Object.entries(REFUND_STATUS_META).map(([value, meta]) => ({ value, label: meta.label })), +]; diff --git a/frontend-admin/src/device.ts b/frontend-admin/src/device.ts new file mode 100644 index 00000000..a02cfb97 --- /dev/null +++ b/frontend-admin/src/device.ts @@ -0,0 +1,43 @@ +// 管理后台设备指纹来源:本地稳定的随机设备 ID(localStorage),随每个请求 +// 通过 X-Device-Id 发送;服务端会与 UA / 语言组合成指纹并绑定登录会话。 +// 清除浏览器存储或更换浏览器会生成新 ID,需要重新登录。 + +const STORAGE_KEY = "mindbase_admin_device_id"; +const ID_PATTERN = /^[A-Za-z0-9_-]{16,128}$/; + +export const DEVICE_ID_HEADER = "X-Device-Id"; + +let cached: string | null = null; + +function randomDeviceId(): string { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function readStoredDeviceId(): string | null { + try { + const value = window.localStorage.getItem(STORAGE_KEY); + return value && ID_PATTERN.test(value) ? value : null; + } catch { + return null; + } +} + +/** 返回稳定的设备 ID;首次调用生成并持久化,storage 不可用时退化为进程内 ID。 */ +export function getDeviceId(): string { + if (cached) return cached; + const stored = readStoredDeviceId(); + if (stored) { + cached = stored; + return stored; + } + const created = randomDeviceId(); + try { + window.localStorage.setItem(STORAGE_KEY, created); + } catch { + // 隐私模式/禁用存储:仍使用进程内 ID(刷新后变化,服务端会要求重新登录) + } + cached = created; + return created; +} diff --git a/frontend-admin/src/pages/Accounts.tsx b/frontend-admin/src/pages/Accounts.tsx new file mode 100644 index 00000000..93bf13f8 --- /dev/null +++ b/frontend-admin/src/pages/Accounts.tsx @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Alert, + App as AntApp, + Button, + Form, + Input, + Modal, + Select, + Space, + Table, + Tag, + Tooltip, +} from "antd"; +import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import type { ConsoleUserItem } from "../api/accounts"; +import { accountsApi } from "../api/accounts"; +import type { AdminRole } from "../api/types"; +import { useAuth } from "../auth"; +import { formatTime } from "../components/format"; + +const ROLE_META: Record = { + admin: { label: "管理员", color: "blue" }, + viewer: { label: "只读", color: "default" }, +}; + +const ROLE_OPTIONS = [ + { value: "viewer", label: "只读(viewer)" }, + { value: "admin", label: "管理员(admin)" }, +]; + +interface CreateFormValues { + username: string; + password: string; + role: AdminRole; +} + +interface PasswordFormValues { + password: string; +} + +export default function Accounts() { + const { me } = useAuth(); + const isAdmin = me?.role === "admin"; + const { message, modal } = AntApp.useApp(); + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + const [saving, setSaving] = useState(false); + + const [createOpen, setCreateOpen] = useState(false); + const [resetTarget, setResetTarget] = useState(null); + const [createForm] = Form.useForm(); + const [passwordForm] = Form.useForm(); + + const load = useCallback(async () => { + // viewer 不请求任何接口(页面主体也会直接给 403) + if (!isAdmin) return; + setLoading(true); + try { + const res = await accountsApi.list(); + setItems(res.items ?? []); + } catch (err) { + message.error(err instanceof Error ? err.message : "加载控制台账户失败"); + } finally { + setLoading(false); + } + }, [isAdmin, message]); + + useEffect(() => { + void load(); + }, [load, reloadToken]); + + const submitCreate = async () => { + const values = await createForm.validateFields().catch(() => null); + if (!values) return; + setSaving(true); + try { + const created = await accountsApi.create(values); + message.success(`账户 ${created.username} 已创建(${ROLE_META[created.role]?.label ?? created.role})`); + setCreateOpen(false); + createForm.resetFields(); + setReloadToken((t) => t + 1); + } catch (err) { + // 409 = 用户名已存在;400 = 参数校验,均透传后端 detail + message.error(err instanceof Error ? err.message : "创建账户失败"); + } finally { + setSaving(false); + } + }; + + const submitResetPassword = async () => { + if (!resetTarget) return; + const values = await passwordForm.validateFields().catch(() => null); + if (!values) return; + setSaving(true); + try { + await accountsApi.resetPassword(resetTarget.id, values.password); + message.success(`账户 ${resetTarget.username} 的密码已重置`); + setResetTarget(null); + passwordForm.resetFields(); + setReloadToken((t) => t + 1); + } catch (err) { + message.error(err instanceof Error ? err.message : "重置密码失败"); + } finally { + setSaving(false); + } + }; + + const confirmDelete = (item: ConsoleUserItem) => { + modal.confirm({ + title: `确认删除账户 ${item.username}?`, + content: "删除后该账户的所有会话立即失效。不能删除当前登录账户,也不能删除最后一个管理员(后端会拦截)。", + okText: "确认删除", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: async () => { + try { + await accountsApi.remove(item.id); + message.success(`账户 ${item.username} 已删除`); + setReloadToken((t) => t + 1); + } catch (err) { + message.error(err instanceof Error ? err.message : "删除账户失败"); + // 抛出让 antd 保持弹窗打开,便于确认原因 + throw err; + } + }, + }); + }; + + const columns: ColumnsType = [ + { title: "ID", dataIndex: "id", width: 70, align: "right" }, + { + title: "用户名", + dataIndex: "username", + width: 200, + render: (v: string) => ( + + {v} + {v === me?.username ? 当前账户 : null} + + ), + }, + { + title: "角色", + dataIndex: "role", + width: 110, + render: (v: AdminRole) => {ROLE_META[v]?.label ?? v}, + }, + { + title: "密码状态", + dataIndex: "mustChangePassword", + width: 120, + render: (v: boolean) => (v ? 需改密 : 正常), + }, + { title: "最近登录", dataIndex: "lastLoginAt", width: 170, render: (v: string | null) => formatTime(v) }, + { title: "创建时间", dataIndex: "createdAt", width: 170, render: (v: string | null) => formatTime(v) }, + { + title: "操作", + key: "action", + width: 180, + render: (_, row: ConsoleUserItem) => { + const isSelf = row.username === me?.username; + const deleteButton = ( + + ); + return ( + + + {isSelf ? ({deleteButton}) : (deleteButton)} + + ); + }, + }, + ]; + + // viewer:直接 403,且不触发任何请求(effect 内已有 isAdmin 守卫) + if (!isAdmin) { + return ( + + ); + } + + return ( +
+ + + + + + + + + rowKey="id" + columns={columns} + dataSource={items} + loading={loading} + scroll={{ x: 1000 }} + locale={{ emptyText: "暂无控制台账户" }} + pagination={false} + /> + + void submitCreate()} + onCancel={() => { + setCreateOpen(false); + createForm.resetFields(); + }} + okText="创建" + cancelText="取消" + confirmLoading={saving} + destroyOnClose + > + form={createForm} layout="vertical" initialValues={{ role: "viewer" }} preserve={false}> + + + + + + + +
MB

MindBase 管理后台

-

分享审核 · 沙箱运维 · 审计追溯

+

分享审核 · 支付运营 · 审计追溯

  • 分享内容审核与放行 @@ -61,6 +62,9 @@ export default function Login() {
  • 沙箱执行、租约与配额
  • +
  • + 支付订单 / 补偿开通 / 退款复核 +
  • 管理操作全量审计
  • @@ -99,6 +103,7 @@ export default function Login() {
    首次登录需修改初始密码;账户与角色由管理员在控制台维护。 + 登录会绑定当前设备与网络,清理浏览器存储、更换设备或跨网段后需重新登录。 若持续返回 502,请确认 app-admin-api 容器状态。
    diff --git a/frontend-admin/src/pages/pay/Events.tsx b/frontend-admin/src/pages/pay/Events.tsx new file mode 100644 index 00000000..8546c9b0 --- /dev/null +++ b/frontend-admin/src/pages/pay/Events.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from "react"; +import { Alert, App as AntApp, Button, Input, Select, Space, Table, Tag } from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { Link } from "react-router-dom"; +import { payApi } from "../../api/pay"; +import type { PayEventQuery, PayMembershipEventItem } from "../../api/pay"; +import { formatTime } from "../../components/format"; +import { EVENT_TYPE_META, EVENT_TYPE_OPTIONS } from "../../components/pay-meta"; + +/** 全局权益事件:uid/type 筛选 + 分页,ADMIN_GRANT 运营审计入口。 */ +export default function PayEvents() { + const { message } = AntApp.useApp(); + const [fUid, setFUid] = useState(""); + const [fType, setFType] = useState(undefined); + const [applied, setApplied] = useState({}); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + + const query = () => { + const uid = fUid.trim(); + if (uid && !/^\d+$/.test(uid)) { + message.warning("UID 必须是正整数"); + return; + } + setApplied({ uid: uid || undefined, type: fType || undefined }); + setPage(1); + }; + + const reset = () => { + setFUid(""); + setFType(undefined); + setApplied({}); + setPage(1); + }; + + useEffect(() => { + let cancelled = false; + setLoading(true); + payApi + .events({ ...applied, page, pageSize }) + .then((res) => { + if (cancelled) return; + setItems(res.items ?? []); + setTotal(res.total ?? res.items?.length ?? 0); + }) + .catch((err: unknown) => { + if (!cancelled) message.error(err instanceof Error ? err.message : "加载权益事件失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [applied, page, pageSize, reloadToken, message]); + + const columns: ColumnsType = [ + { + title: "时间", + dataIndex: "createdAt", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "UID", + dataIndex: "uid", + width: 150, + render: (v: string) => ( + {v} + ), + }, + { + title: "类型", + dataIndex: "type", + width: 130, + render: (v: string) => { + const meta = EVENT_TYPE_META[v]; + return {meta?.label ?? v}; + }, + }, + { + title: "订单号", + dataIndex: "orderNo", + width: 210, + ellipsis: true, + render: (v: string | null) => v || "-", + }, + { + title: "天数", + dataIndex: "days", + width: 90, + align: "right", + render: (v: number) => v + " 天", + }, + { + title: "变更前到期", + dataIndex: "expireBefore", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "变更后到期", + dataIndex: "expireAfter", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "原因", + dataIndex: "reason", + width: 240, + ellipsis: true, + render: (v: string | null) => v || "-", + }, + { title: "ID", dataIndex: "id", width: 80, align: "right" }, + ]; + + return ( +
    + + + setFUid(e.target.value)} + onPressEnter={query} + style={{ width: 180 }} + /> + + + + + + + + + + + + + + + {result ? ( + + + + ) : null} + + + + + } + > + + rowKey="id" + size="small" + columns={columns} + dataSource={events} + loading={eventsLoading} + scroll={{ x: 1000 }} + locale={{ emptyText: payDisabled ? "支付管理未配置" : "暂无补偿开通记录" }} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (t) => `共 ${t} 条`, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + +
    + ); +} diff --git a/frontend-admin/src/pages/pay/MemberEvents.tsx b/frontend-admin/src/pages/pay/MemberEvents.tsx new file mode 100644 index 00000000..c5ac4f83 --- /dev/null +++ b/frontend-admin/src/pages/pay/MemberEvents.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; +import { App as AntApp, Button, Space, Table, Tag, Typography } from "antd"; +import { ArrowLeftOutlined, ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { useNavigate, useParams } from "react-router-dom"; +import { payApi } from "../../api/pay"; +import type { PayMembershipEventItem } from "../../api/pay"; +import { formatTime } from "../../components/format"; +import { EVENT_TYPE_META } from "../../components/pay-meta"; + +/** 单人权益事件:/pay/memberships/:uid/events。 */ +export default function PayMemberEvents() { + const { message } = AntApp.useApp(); + const navigate = useNavigate(); + const { uid = "" } = useParams<{ uid: string }>(); + + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [loading, setLoading] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + payApi + .memberEvents(uid, { page, pageSize }) + .then((res) => { + if (cancelled) return; + setItems(res.items ?? []); + setTotal(res.total ?? res.items?.length ?? 0); + }) + .catch((err: unknown) => { + if (!cancelled) message.error(err instanceof Error ? err.message : "加载权益事件失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [uid, page, pageSize, reloadToken, message]); + + const columns: ColumnsType = [ + { + title: "时间", + dataIndex: "createdAt", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "类型", + dataIndex: "type", + width: 130, + render: (v: string) => { + const meta = EVENT_TYPE_META[v]; + return {meta?.label ?? v}; + }, + }, + { + title: "订单号", + dataIndex: "orderNo", + width: 210, + ellipsis: true, + render: (v: string | null) => v || "-", + }, + { + title: "天数", + dataIndex: "days", + width: 90, + align: "right", + render: (v: number) => v + " 天", + }, + { + title: "变更前到期", + dataIndex: "expireBefore", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "变更后到期", + dataIndex: "expireAfter", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "原因", + dataIndex: "reason", + width: 240, + ellipsis: true, + render: (v: string | null) => v || "-", + }, + { title: "ID", dataIndex: "id", width: 80, align: "right" }, + ]; + + return ( +
    + + + + + {"用户 " + uid + " 的权益流水"} + + + + + rowKey="id" + columns={columns} + dataSource={items} + loading={loading} + scroll={{ x: 1300 }} + locale={{ emptyText: "暂无权益事件" }} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (t) => "共 " + t + " 条", + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> +
    + ); +} diff --git a/frontend-admin/src/pages/pay/Memberships.tsx b/frontend-admin/src/pages/pay/Memberships.tsx new file mode 100644 index 00000000..55a15473 --- /dev/null +++ b/frontend-admin/src/pages/pay/Memberships.tsx @@ -0,0 +1,283 @@ +import { useEffect, useState } from "react"; +import { + Alert, + App as AntApp, + Button, + Card, + Descriptions, + Input, + Space, + Spin, + Table, + Tag, +} from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { payApi } from "../../api/pay"; +import type { PayMembershipItem } from "../../api/pay"; +import { formatTime } from "../../components/format"; +import { TIER_META } from "../../components/pay-meta"; + +/** 会员:UID 精确查询 + 全量分页;行操作「权益流水」跳单人事件流。 */ +export default function PayMemberships() { + const { message } = AntApp.useApp(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const initialUid = searchParams.get("uid") ?? ""; + const [uidInput, setUidInput] = useState(initialUid); + const [lookupUid, setLookupUid] = useState( + /^\d+$/.test(initialUid) ? initialUid : null, + ); + const [reloadToken, setReloadToken] = useState(0); + + // 从其他页面带 ?uid= 进入时同步查询目标 + useEffect(() => { + const uid = searchParams.get("uid") ?? ""; + if (!uid || !/^\d+$/.test(uid)) return; + setUidInput(uid); + setLookupUid(uid); + }, [searchParams]); + + // 单人会员态 + const [membership, setMembership] = useState(null); + const [lookupLoading, setLookupLoading] = useState(false); + const [lookupDone, setLookupDone] = useState(false); + const [lookupError, setLookupError] = useState(null); + + // 全量列表 + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!lookupUid) { + setMembership(null); + setLookupDone(false); + setLookupError(null); + return; + } + let cancelled = false; + setLookupLoading(true); + setLookupDone(false); + setLookupError(null); + payApi + .membership(lookupUid) + .then((res) => { + if (cancelled) return; + setMembership(res.membership ?? null); + setLookupDone(true); + }) + .catch((err: unknown) => { + if (cancelled) return; + const detail = err instanceof Error ? err.message : "加载会员信息失败"; + setLookupError(detail); + message.error(detail); + }) + .finally(() => { + if (!cancelled) setLookupLoading(false); + }); + return () => { + cancelled = true; + }; + }, [lookupUid, reloadToken, message]); + + useEffect(() => { + if (lookupUid) return; + let cancelled = false; + setLoading(true); + payApi + .memberships({ page, pageSize }) + .then((res) => { + if (cancelled) return; + setItems(res.items ?? []); + setTotal(res.total ?? res.items?.length ?? 0); + }) + .catch((err: unknown) => { + if (!cancelled) message.error(err instanceof Error ? err.message : "加载会员列表失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [lookupUid, page, pageSize, reloadToken, message]); + + const submitLookup = () => { + const uid = uidInput.trim(); + if (!/^\d+$/.test(uid)) { + message.warning("请输入正整数 UID"); + return; + } + setLookupUid(uid); + }; + + const clearLookup = () => { + setLookupUid(null); + setUidInput(""); + setPage(1); + }; + + const tierTag = (tier: string) => { + const meta = TIER_META[tier]; + return {meta?.label ?? tier}; + }; + + const activeTag = (active: boolean) => + active ? 生效中 : 已过期; + + const columns: ColumnsType = [ + { + title: "UID", + dataIndex: "uid", + width: 150, + render: (v: string) => {v}, + }, + { + title: "档位", + dataIndex: "tier", + width: 100, + render: (v: string) => tierTag(v), + }, + { + title: "状态", + dataIndex: "active", + width: 100, + render: (v: boolean) => activeTag(v), + }, + { + title: "到期时间", + dataIndex: "expireAt", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "自动续费", + dataIndex: "autoRenew", + width: 100, + render: (v: boolean) => (v ? "是" : "否"), + }, + { + title: "最近订单号", + dataIndex: "lastOrderNo", + width: 210, + ellipsis: true, + render: (v: string | null) => v || "-", + }, + { + title: "更新时间", + dataIndex: "updatedAt", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "操作", + key: "op", + width: 110, + fixed: "right", + render: (_, r) => ( + + ), + }, + ]; + + return ( +
    + + setUidInput(e.target.value)} + onPressEnter={submitLookup} + style={{ width: 240 }} + /> + + {lookupUid ? : null} + + + + } + > + + {membership.uid} + {tierTag(membership.tier)} + {activeTag(membership.active)} + {formatTime(membership.expireAt)} + + {membership.autoRenew ? "是" : "否"} + + + {membership.lastOrderNo || "-"} + + {formatTime(membership.createdAt)} + {formatTime(membership.updatedAt)} + + + ) : null} + + ) : ( + + rowKey="uid" + columns={columns} + dataSource={items} + loading={loading} + scroll={{ x: 1200 }} + locale={{ emptyText: "暂无会员记录" }} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (t) => "共 " + t + " 条", + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + )} +
    + ); +} diff --git a/frontend-admin/src/pages/pay/OrderDetail.tsx b/frontend-admin/src/pages/pay/OrderDetail.tsx new file mode 100644 index 00000000..6d5b6fc0 --- /dev/null +++ b/frontend-admin/src/pages/pay/OrderDetail.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from "react"; +import { + Alert, + App as AntApp, + Button, + Descriptions, + Space, + Spin, + Table, + Tag, + Tooltip, + Typography, +} from "antd"; +import { ArrowLeftOutlined, ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { ApiError } from "../../api/client"; +import { payApi } from "../../api/pay"; +import type { PayCallbackItem, PayOrderItem } from "../../api/pay"; +import { formatCents, formatTime } from "../../components/format"; +import { CHANNEL_LABEL, ORDER_STATUS_META } from "../../components/pay-meta"; + +/** 订单详情:Descriptions + 渠道回调流水 + 返回。 */ +export default function PayOrderDetail() { + const { message } = AntApp.useApp(); + const navigate = useNavigate(); + const { orderNo = "" } = useParams<{ orderNo: string }>(); + const [searchParams] = useSearchParams(); + const tradeNo = searchParams.get("tradeNo") ?? undefined; + + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(null); + const [reloadToken, setReloadToken] = useState(0); + + const [cbItems, setCbItems] = useState([]); + const [cbTotal, setCbTotal] = useState(0); + const [cbPage, setCbPage] = useState(1); + const [cbPageSize, setCbPageSize] = useState(10); + const [cbLoading, setCbLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setNotFound(false); + setError(null); + payApi + .order(orderNo, tradeNo) + .then((res) => { + if (!cancelled) setOrder(res.order); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof ApiError && err.status === 404) { + setOrder(null); + setNotFound(true); + return; + } + const detail = err instanceof Error ? err.message : "加载订单详情失败"; + setError(detail); + message.error(detail); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [orderNo, tradeNo, reloadToken, message]); + + // 切换订单时回调分页归位 + useEffect(() => { + setCbPage(1); + }, [orderNo]); + + const resolvedNo = order?.orderNo ?? orderNo; + + useEffect(() => { + if (!resolvedNo || notFound) return; + let cancelled = false; + setCbLoading(true); + payApi + .orderCallbacks(resolvedNo, { page: cbPage, pageSize: cbPageSize }) + .then((res) => { + if (cancelled) return; + setCbItems(res.items ?? []); + setCbTotal(res.total ?? res.items?.length ?? 0); + }) + .catch((err: unknown) => { + if (!cancelled) message.error(err instanceof Error ? err.message : "加载回调流水失败"); + }) + .finally(() => { + if (!cancelled) setCbLoading(false); + }); + return () => { + cancelled = true; + }; + }, [resolvedNo, notFound, cbPage, cbPageSize, reloadToken, message]); + + const statusTag = (status: string) => { + const meta = ORDER_STATUS_META[status]; + return {meta?.label ?? status}; + }; + + const callbackColumns: ColumnsType = [ + { + title: "时间", + dataIndex: "createdAt", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "渠道", + dataIndex: "channel", + width: 100, + render: (v: string) => CHANNEL_LABEL[v] ?? v, + }, + { title: "验签结果", dataIndex: "signatureResult", width: 120 }, + { title: "处理结果", dataIndex: "handleResult", width: 140 }, + { + title: "信息", + dataIndex: "message", + width: 220, + ellipsis: true, + render: (v: string | null) => v || "-", + }, + { + title: "脱敏报文", + dataIndex: "payloadMasked", + width: 280, + render: (v: string | null) => + v ? ( + {v}} + > + + {v} + + + ) : ( + "-" + ), + }, + ]; + + return ( +
    + + + + + {tradeNo ? "按渠道交易号兜底查询:" + tradeNo : "订单号:" + orderNo} + + + + {loading ? ( +
    + +
    + ) : error ? ( + setReloadToken((t) => t + 1)}> + 重试 + + } + /> + ) : notFound ? ( + navigate("/pay/orders")}> + 返回订单列表 + + } + /> + ) : order ? ( + <> + + {order.orderNo} + + + {order.uid} + + + {order.productTitle} + {order.skuCode} + {order.durationDays + " 天"} + {formatCents(order.amountCents)} + + {CHANNEL_LABEL[order.channel] ?? order.channel} + + {statusTag(order.status)} + {order.version} + {order.channelTradeNo || "-"} + {order.idempotencyKey || "-"} + {order.failReason || "-"} + {formatTime(order.createdAt)} + {formatTime(order.expiresAt)} + {formatTime(order.paidAt)} + {formatTime(order.closedAt)} + {formatTime(order.updatedAt)} + + +
    渠道回调流水
    + + rowKey={(r) => r.orderNo + "-" + r.createdAt + "-" + r.handleResult} + size="small" + columns={callbackColumns} + dataSource={cbItems} + loading={cbLoading} + scroll={{ x: 1000 }} + locale={{ emptyText: "暂无回调流水" }} + pagination={{ + current: cbPage, + pageSize: cbPageSize, + total: cbTotal, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (t) => "共 " + t + " 条", + onChange: (p, ps) => { + setCbPage(p); + setCbPageSize(ps); + }, + }} + /> + + ) : null} +
    + ); +} diff --git a/frontend-admin/src/pages/pay/Orders.tsx b/frontend-admin/src/pages/pay/Orders.tsx new file mode 100644 index 00000000..e7bdeb0c --- /dev/null +++ b/frontend-admin/src/pages/pay/Orders.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from "react"; +import { + App as AntApp, + Button, + DatePicker, + Input, + Select, + Space, + Table, + Tag, + Tooltip, + Typography, +} from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import dayjs from "dayjs"; +import { Link } from "react-router-dom"; +import { payApi } from "../../api/pay"; +import type { PayOrderItem, PayOrderQuery } from "../../api/pay"; +import { formatCents, formatTime } from "../../components/format"; +import { + CHANNEL_LABEL, + CHANNEL_OPTIONS, + ORDER_STATUS_META, + ORDER_STATUS_OPTIONS, +} from "../../components/pay-meta"; + +const { RangePicker } = DatePicker; + +/** 订单列表:订单号/UID/交易号/状态/渠道/创建时间范围筛选 + 分页。 */ +export default function PayOrders() { + const { message } = AntApp.useApp(); + const [fOrderNo, setFOrderNo] = useState(""); + const [fUid, setFUid] = useState(""); + const [fTradeNo, setFTradeNo] = useState(""); + const [fStatus, setFStatus] = useState(undefined); + const [fChannel, setFChannel] = useState(undefined); + const [range, setRange] = useState<[string, string] | null>(null); + + const [applied, setApplied] = useState({}); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + + const query = () => { + const uid = fUid.trim(); + if (uid && !/^\d+$/.test(uid)) { + message.warning("UID 必须是正整数"); + return; + } + setApplied({ + orderNo: fOrderNo.trim() || undefined, + uid: uid || undefined, + tradeNo: fTradeNo.trim() || undefined, + status: fStatus || undefined, + channel: fChannel || undefined, + createdFrom: range?.[0], + createdTo: range?.[1], + }); + setPage(1); + }; + + const reset = () => { + setFOrderNo(""); + setFUid(""); + setFTradeNo(""); + setFStatus(undefined); + setFChannel(undefined); + setRange(null); + setApplied({}); + setPage(1); + }; + + useEffect(() => { + let cancelled = false; + setLoading(true); + payApi + .orders({ ...applied, page, pageSize }) + .then((res) => { + if (cancelled) return; + setItems(res.items ?? []); + setTotal(res.total ?? res.items?.length ?? 0); + }) + .catch((err: unknown) => { + if (!cancelled) message.error(err instanceof Error ? err.message : "加载订单列表失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [applied, page, pageSize, reloadToken, message]); + + const columns: ColumnsType = [ + { + title: "订单号", + dataIndex: "orderNo", + width: 210, + fixed: "left", + render: (v: string) => {v}, + }, + { + title: "UID", + dataIndex: "uid", + width: 150, + render: (v: string) => ( + + {v} + + ), + }, + { + title: "商品", + key: "product", + width: 200, + render: (_, r) => ( +
    +
    {r.productTitle}
    + + {r.skuCode} + +
    + ), + }, + { + title: "天数", + dataIndex: "durationDays", + width: 80, + align: "right", + render: (v: number) => v + " 天", + }, + { + title: "金额", + dataIndex: "amountCents", + width: 110, + align: "right", + render: (v: number) => formatCents(v), + }, + { + title: "渠道", + dataIndex: "channel", + width: 100, + render: (v: string) => CHANNEL_LABEL[v] ?? v, + }, + { + title: "状态", + dataIndex: "status", + width: 110, + render: (v: string) => { + const meta = ORDER_STATUS_META[v]; + return {meta?.label ?? v}; + }, + }, + { + title: "创建时间", + dataIndex: "createdAt", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "支付时间", + dataIndex: "paidAt", + width: 170, + render: (v: string | null) => formatTime(v), + }, + ]; + + return ( +
    + + setFOrderNo(e.target.value)} + onPressEnter={query} + style={{ width: 210 }} + /> + setFUid(e.target.value)} + onPressEnter={query} + style={{ width: 160 }} + /> + setFTradeNo(e.target.value)} + onPressEnter={query} + style={{ width: 210 }} + /> + + { + setRange(dates && dates[0] && dates[1] ? [dateStrings[0], dateStrings[1]] : null); + }} + /> + + +
    + ); +} diff --git a/frontend-admin/src/pages/pay/Products.tsx b/frontend-admin/src/pages/pay/Products.tsx new file mode 100644 index 00000000..1e477025 --- /dev/null +++ b/frontend-admin/src/pages/pay/Products.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; +import { Alert, App as AntApp, Button, Space, Table, Tag } from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { payApi } from "../../api/pay"; +import type { PayProductItem } from "../../api/pay"; +import { formatCents, formatTime } from "../../components/format"; + +const PRODUCT_STATUS_META: Record = { + ON_SALE: { label: "在售", color: "green" }, + OFF_SALE: { label: "下架", color: "default" }, +}; + +/** SKU 目录:只读,改价走 app-pay 运营改库流程。 */ +export default function PayProducts() { + const { message } = AntApp.useApp(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + payApi + .products() + .then((res) => { + if (!cancelled) setItems(res.items ?? []); + }) + .catch((err: unknown) => { + if (!cancelled) message.error(err instanceof Error ? err.message : "加载 SKU 目录失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [reloadToken, message]); + + const columns: ColumnsType = [ + { title: "编码", dataIndex: "code", width: 180 }, + { title: "名称", dataIndex: "title", width: 220 }, + { + title: "天数", + dataIndex: "durationDays", + width: 100, + align: "right", + render: (v: number) => v + " 天", + }, + { + title: "价格", + dataIndex: "priceCents", + width: 120, + align: "right", + render: (v: number) => formatCents(v), + }, + { + title: "状态", + dataIndex: "status", + width: 100, + render: (v: string) => { + const meta = PRODUCT_STATUS_META[v]; + return {meta?.label ?? v}; + }, + }, + { title: "排序", dataIndex: "sort", width: 80, align: "right" }, + { + title: "创建时间", + dataIndex: "createdAt", + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: "更新时间", + dataIndex: "updatedAt", + width: 170, + render: (v: string) => formatTime(v), + }, + ]; + + return ( +
    + + + + + + rowKey="code" + columns={columns} + dataSource={items} + loading={loading} + scroll={{ x: 1100 }} + locale={{ emptyText: "暂无 SKU" }} + pagination={false} + /> +
    + ); +} diff --git a/frontend-admin/src/pages/pay/Refunds.tsx b/frontend-admin/src/pages/pay/Refunds.tsx new file mode 100644 index 00000000..2b559992 --- /dev/null +++ b/frontend-admin/src/pages/pay/Refunds.tsx @@ -0,0 +1,289 @@ +import { useCallback, useEffect, useState } from "react"; +import { Alert, App as AntApp, Button, Input, Modal, Select, Space, Table, Tag } from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { ApiError } from "../../api/client"; +import type { RefundApprovalStatus, RefundItem } from "../../api/pay"; +import { approveRefund, listRefunds, rejectRefund } from "../../api/pay"; +import { useAuth } from "../../auth"; +import { formatTime } from "../../components/format"; + +const STATUS_META: Record = { + PENDING: { label: "待复核", color: "orange" }, + APPROVED: { label: "已通过", color: "green" }, + REJECTED: { label: "已驳回", color: "red" }, +}; + +const STATUS_OPTIONS = [ + { value: "PENDING", label: "待复核" }, + { value: "APPROVED", label: "已通过" }, + { value: "REJECTED", label: "已驳回" }, + { value: "ALL", label: "全部状态" }, +]; + +/** approve 的 reason 可选,缺省记为「复核通过」 */ +const DEFAULT_APPROVE_REASON = "复核通过"; + +export default function Refunds() { + const { me } = useAuth(); + const isAdmin = me?.role === "admin"; + const { message, modal } = AntApp.useApp(); + + // 默认待复核;「全部状态」显式发送 ALL(后端缺省值是 PENDING,空串会被当成缺省) + const [status, setStatus] = useState("PENDING"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + /** 404 = 退款复核未配置(整组隐身) */ + const [notConfigured, setNotConfigured] = useState(false); + /** 503 = 支付模块未配置(pay_rdbms.url 缺失 / PayDB 不可用) */ + const [payDisabled, setPayDisabled] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + + const [approveTarget, setApproveTarget] = useState(null); + const [approveReason, setApproveReason] = useState(""); + const [approving, setApproving] = useState(false); + const [rejectingOrderNo, setRejectingOrderNo] = useState(null); + + const load = useCallback(async () => { + // viewer 不请求任何接口;未配置 / 支付不可用时不重试 + if (!isAdmin || notConfigured || payDisabled) return; + setLoading(true); + try { + const res = await listRefunds({ status, page, pageSize }); + setItems(res.items ?? []); + setTotal(res.total ?? res.items?.length ?? 0); + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + setNotConfigured(true); + return; + } + if (err instanceof ApiError && err.status === 503) { + setPayDisabled(true); + return; + } + message.error(err instanceof Error ? err.message : "加载退款复核列表失败"); + } finally { + setLoading(false); + } + }, [isAdmin, notConfigured, payDisabled, status, page, pageSize, message]); + + useEffect(() => { + void load(); + }, [load, reloadToken]); + + const submitApprove = async () => { + if (!approveTarget) return; + const reason = approveReason.trim() || DEFAULT_APPROVE_REASON; + setApproving(true); + try { + await approveRefund(approveTarget.orderNo, reason); + message.success(`订单 ${approveTarget.orderNo} 已确认退款`); + setApproveTarget(null); + setApproveReason(""); + setReloadToken((t) => t + 1); + } catch (err) { + // 上游失败 → 502,detail 直接展示 + if (err instanceof ApiError && err.status === 404) { + setApproveTarget(null); + setNotConfigured(true); + return; + } + message.error(err instanceof Error ? err.message : "确认退款失败"); + } finally { + setApproving(false); + } + }; + + /** 驳回不带 reason(app-pay 端点只接受 operator),仅确认弹窗,body 发 {} */ + const confirmReject = (item: RefundItem) => { + modal.confirm({ + title: "确认驳回该退款复核?", + content: `订单 ${item.orderNo}:驳回后该笔退款不再执行,操作会记入审计日志。`, + okText: "确认驳回", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: async () => { + setRejectingOrderNo(item.orderNo); + try { + await rejectRefund(item.orderNo); + message.success(`订单 ${item.orderNo} 已驳回`); + setReloadToken((t) => t + 1); + } catch (err) { + message.error(err instanceof Error ? err.message : "驳回退款复核失败"); + // 抛出让 antd 保持弹窗打开,便于重试 + throw err; + } finally { + setRejectingOrderNo(null); + } + }, + }); + }; + + const columns: ColumnsType = [ + { title: "订单号", dataIndex: "orderNo", width: 220, ellipsis: true }, + { title: "申请人", dataIndex: "maker", width: 120, ellipsis: true }, + { title: "申请原因", dataIndex: "makerReason", ellipsis: true, render: (v: string) => v || "-" }, + { + title: "状态", + dataIndex: "status", + width: 100, + render: (v: string) => { + const meta = STATUS_META[v]; + return {meta?.label ?? v}; + }, + }, + { title: "申请时间", dataIndex: "createdAt", width: 170, render: (v: string) => formatTime(v) }, + { title: "复核人", dataIndex: "checker", width: 120, render: (v: string | null) => v || "-" }, + { title: "复核时间", dataIndex: "decidedAt", width: 170, render: (v: string | null) => formatTime(v) }, + { title: "复核原因", dataIndex: "checkReason", ellipsis: true, render: (v: string | null) => v || "-" }, + { + title: "操作", + key: "action", + width: 160, + fixed: "right", + render: (_, row: RefundItem) => + row.status === "PENDING" ? ( + + + + + ) : ( + "-" + ), + }, + ]; + + // viewer:直接 403,且不触发任何请求(effect 内已有 isAdmin 守卫) + if (!isAdmin) { + return ( + + ); + } + + if (notConfigured) { + return ( + + ); + } + + return ( +
    + + + {payDisabled ? ( + + ) : null} + + +