diff --git a/configs/config.yaml b/configs/config.yaml index b9cfdee..e93d374 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -6,13 +6,12 @@ server: data: database: driver: mysql - # Supply production credentials through KRA_DATA_DATABASE_*. - source: "" - host: 127.0.0.1 - port: "3306" + source: root:+V1veMv3Vv_4VdqtZ1f#L7*+@tcp(47.97.87.141:13306)/yzyz_live?charset=utf8mb4&parseTime=True&loc=Local + host: 47.97.87.141 + port: "13306" user: root - password: "" - name: yzyzlive + password: +V1veMv3Vv_4VdqtZ1f#L7*+ + name: yzyz_live config: charset=utf8mb4&parseTime=True&loc=Local path: "" alias_name: "" @@ -27,48 +26,15 @@ data: redis: network: tcp name: default - addr: 127.0.0.1:6379 - password: "" + addr: live-redis:6379 + password: '1_S7cwT$hNU7C^y$fALKX99U' db: 0 use_cluster: false cluster_addrs: [] read_timeout: 0.200s write_timeout: 0.200s - # Additional databases use alias_name as the lookup key. Disabled entries - # remain available as configuration examples without opening connections. database_list: [] - # Example: - # database_list: - # - driver: mysql - # source: "" - # host: 127.0.0.1 - # port: "3306" - # user: root - # password: "" - # name: business - # config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4 - # path: "" - # alias_name: business - # disable: true - # prefix: "" - # engine: InnoDB - # log_mode: info - # max_idle_conns: 10 - # max_open_conns: 100 - # conn_max_lifetime: 3600 - # singular: false redis_list: [] - # Example: - # redis_list: - # - network: tcp - # name: cache - # addr: 127.0.0.1:6379 - # password: "" - # db: 0 - # use_cluster: false - # cluster_addrs: [] - # read_timeout: 0.2s - # write_timeout: 0.2s mongo: coll: "" options: "" @@ -85,11 +51,9 @@ data: - host: "" port: "" admin: - # The administration system router prefix maps to this transport prefix. router_prefix: "" jwt: - # Generated during first-install, or provide KRA_ADMIN_JWT_SIGNING_KEY locally. - signing_key: "" + signing_key: 0848966d-b15a-4e4d-a527-7024910ba429 expires_time: 604800s buffer_time: 86400s issuer: kra @@ -102,7 +66,6 @@ admin: store_path: uploads/file path_prefix: uploads/file media: - # Upload chunks are stored below uploads/chunks in the selected storage backend. session_ttl: 24 max_file_size: "0" chunk_dir: uploads/chunks @@ -129,23 +92,15 @@ admin: access_resp_data: true access_req_headers: true access_log_max_bytes: 1024 - # High-volume request and SQL details remain available in logs/http and logs/sql. file_only_modules: - http - sql cors: mode: allow-all whitelist: [] - # Example: - # whitelist: - # - allow_origin: https://admin.example.com - # allow_headers: Content-Type,Authorization,X-Token - # allow_methods: POST,GET,PUT,DELETE,OPTIONS - # expose_headers: Content-Length,Content-Type - # allow_credentials: true app: node: "" app_id: kra - env: development + env: production disk_list: - mount_point: / diff --git a/deploy/build.ps1 b/deploy/build.ps1 new file mode 100644 index 0000000..1c76510 --- /dev/null +++ b/deploy/build.ps1 @@ -0,0 +1,122 @@ +# ============================================================ +# 本机一键打包脚本(Windows PowerShell) +# 流程: go build(linux/amd64) -> web/frontend 两个 vite build +# -> 渲染 web/nginx.k8s.conf(去 BOM) -> docker build -> push Harbor +# +# 用法: +# .\deploy\build.ps1 # 打包当前代码并推送 (tag = commit sha + latest) +# .\deploy\build.ps1 -Tag v1.0.2 # 使用指定 tag +# .\deploy\build.ps1 -NoPush # 只构建不推送 +# +# 依赖: go / node(corepack pnpm) / docker(Harbor 登录态已存于 Docker Desktop 凭据库) +# ============================================================ +param( + [string]$Tag = "", + [switch]$NoPush +) + +$ErrorActionPreference = 'Stop' + +# 脚本所在目录的上一级即项目根目录,任何 cwd 下运行都正确 +$Root = Split-Path -Parent $PSScriptRoot +$Registry = 'harbor.fuhua91.com' +$Project = 'live' + +function Invoke-Step { + param([string]$Name, [scriptblock]$Block) + Write-Host "==> $Name" -ForegroundColor Cyan + & $Block + if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { + Write-Host "错误: $Name 失败 (exit $LASTEXITCODE)" -ForegroundColor Red + exit $LASTEXITCODE + } +} + +# 前端包管理器: 本机 pnpm 通常不在 PATH,回退 corepack pnpm +function Invoke-Pnpm { + param([string]$Dir, [string[]]$PnpmArgs) + Push-Location (Join-Path $Root $Dir) + try { + if (Get-Command pnpm -ErrorAction SilentlyContinue) { + & pnpm @PnpmArgs + } else { + & corepack pnpm @PnpmArgs + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Pop-Location + } +} + +function Build-Frontend { + param([string]$Dir) + if (-not (Test-Path (Join-Path $Root "$Dir\node_modules"))) { + Invoke-Step "[$Dir] pnpm install" { Invoke-Pnpm -Dir $Dir -PnpmArgs @('install', '--frozen-lockfile') } + } + Invoke-Step "[$Dir] vite build" { Invoke-Pnpm -Dir $Dir -PnpmArgs @('build') } +} + +Set-Location $Root + +# ---------- 0. 版本号 ---------- +if (-not $Tag) { + $Tag = (& git rev-parse --short=12 HEAD).Trim() + if ($LASTEXITCODE -ne 0) { Write-Host '错误: 无法获取 git commit sha' -ForegroundColor Red; exit 1 } +} +$BackendImage = "$Registry/$Project/backend:$Tag" +$FrontendImage = "$Registry/$Project/frontend:$Tag" +$LivewebImage = "$Registry/$Project/live-web:$Tag" +Write-Host "==> 打包版本: $Tag" + +# ---------- 1. 后端二进制 (必须 linux/amd64,否则容器内 Exec format error) ---------- +Invoke-Step 'go build (GOOS=linux GOARCH=amd64)' { + $env:GOOS = 'linux'; $env:GOARCH = 'amd64'; $env:CGO_ENABLED = '0' + go build -ldflags "-X main.Version=$Tag" -o ./bin/kratos-admin ./cmd +} + +# ---------- 2. 两个前端 ---------- +Build-Frontend 'web' # admin 管理后台 +Build-Frontend 'frontend' # live 用户端(PC 直播页) + +# ---------- 3. 渲染 nginx.k8s.conf ---------- +# PowerShell Set-Content 会写 UTF-8 BOM,导致 nginx 报 unknown directive; +# 必须用 WriteAllBytes 写无 BOM 内容。KRA_APP=live -> proxy_pass http://live-backend:8000 +Invoke-Step '渲染 web/nginx.k8s.conf (KRA_APP=live, 去 BOM)' { + $src = Get-Content -Raw (Join-Path $Root 'web\.docker-compose\nginx\conf.d\my.conf') + $rendered = $src -replace '\$\{KRA_APP\}', $Project + $dst = Join-Path $Root 'web\nginx.k8s.conf' + [System.IO.File]::WriteAllBytes($dst, [System.Text.Encoding]::UTF8.GetBytes($rendered)) + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # 校验文件头非 EF BB BF + $head = [System.IO.File]::ReadAllBytes($dst)[0..1] + if ($head[0] -eq 0xEF -and $head[1] -eq 0xBB) { + Write-Host '错误: nginx.k8s.conf 仍含 BOM' -ForegroundColor Red + exit 1 + } +} + +# ---------- 4. docker build ---------- +Invoke-Step "docker build $BackendImage" { + docker build -t $BackendImage -t "$Registry/$Project/backend:latest" $Root +} +Invoke-Step "docker build $FrontendImage" { + docker build -t $FrontendImage -t "$Registry/$Project/frontend:latest" (Join-Path $Root 'web') +} +Invoke-Step "docker build $LivewebImage" { + docker build -t $LivewebImage -t "$Registry/$Project/live-web:latest" (Join-Path $Root 'frontend') +} + +# ---------- 5. docker push ---------- +if (-not $NoPush) { + foreach ($img in @($BackendImage, $FrontendImage, $LivewebImage)) { + Invoke-Step "docker push ${img}:$Tag + latest" { + docker push $img + docker push ($img -replace ":[^:]+$", ':latest') + } + } + Write-Host "" + Write-Host "完成。服务器上执行: ./deploy.sh $Tag (或不带参数拉 latest)" -ForegroundColor Green +} else { + Write-Host "" + Write-Host "构建完成 (-NoPush 未推送)。镜像: $BackendImage / $FrontendImage / $LivewebImage" -ForegroundColor Green +} diff --git a/deploy/deploy.sh b/deploy/deploy.sh index e0f4d15..bd95735 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -26,8 +26,8 @@ FRONTEND_PORT=9501 # admin 管理后台 nginx 容器 BACKEND_PORT=9502 # 后端 API/WebSocket 容器 LIVEWEB_PORT=9503 # live 用户端(PC 直播页) nginx 容器 LIVE_UDP_PORT="${KRA_LIVE_UDP_PORT:-9504}" # WebRTC 音视频固定 UDP 端口(与 9501-9503 顺延配套) -LIVE_PUBLIC_IP="${KRA_LIVE_PUBLIC_IP:-192.168.2.226}" # 留空时从站点域名解析 -LIVE_HOST="${KRA_LIVE_HOST:-live.fuhua91.com}" +LIVE_PUBLIC_IP="${KRA_LIVE_PUBLIC_IP:-}" # 留空时从站点域名解析 +LIVE_HOST="${KRA_LIVE_HOST:-live.yuzou99.com}" NETWORK="live-net" BACKEND_NAME="live-backend" FRONTEND_NAME="live-frontend" @@ -39,6 +39,18 @@ BACKEND_IMAGE="$REGISTRY/$PROJECT/backend:$TAG" FRONTEND_IMAGE="$REGISTRY/$PROJECT/frontend:$TAG" LIVEWEB_IMAGE="$REGISTRY/$PROJECT/live-web:$TAG" +# 基础环境检查(Docker / MySQL / Redis 由 server-init.sh 负责) +if ! command -v docker >/dev/null 2>&1; then + echo "错误: 服务器未安装 Docker,请先执行基础环境初始化脚本:" + echo " bash server-init.sh" + exit 1 +fi +if ! docker ps --format '{{.Names}}' | grep -qx 'live-mysql' || \ + ! docker ps --format '{{.Names}}' | grep -qx 'live-redis'; then + echo "警告: live-mysql / live-redis 容器未在运行,后端将连不上数据库。" + echo " 请先执行基础环境初始化脚本: bash server-init.sh" +fi + echo "==> 部署版本: $TAG" # 1. 准备挂载目录 @@ -54,8 +66,9 @@ fi if [ -z "$LIVE_PUBLIC_IP" ]; then # 1) 域名解析,过滤内网/回环地址(服务器 /etc/hosts 可能指向内网) - LIVE_PUBLIC_IP="$(getent ahostsv4 "$LIVE_HOST" | awk '{print $1}' | grep -E '^[0-9]+(\.[0-9]+){3}$' \ - | grep -vE '^(10\.|127\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' | head -n1)" + # getent 域名不存在时返回非零且无输出,须吞掉退出码,否则 pipefail+set -e 会静默退出 + LIVE_PUBLIC_IP="$( { timeout 10 getent ahostsv4 "$LIVE_HOST" 2>/dev/null || true; } | awk '{print $1}' | grep -E '^[0-9]+(\.[0-9]+){3}$' \ + | grep -vE '^(10\.|127\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' | head -n1 || true)" fi if [ -z "$LIVE_PUBLIC_IP" ]; then # 2) 域名取不到公网 IP 时,回退查询公网出口 IP(云主机 EIP 1:1 NAT 场景一致) diff --git a/deploy/server-init.sh b/deploy/server-init.sh new file mode 100644 index 0000000..63f4609 --- /dev/null +++ b/deploy/server-init.sh @@ -0,0 +1,770 @@ +#!/bin/bash +# ============================================================ +# live.fuhua91.com 生产服务器基础环境初始化脚本(幂等,可重复执行) +# +# 功能: +# 1. 安装 Docker(自动识别 CentOS/Rocky/Alma/Ubuntu/Debian) +# 2. 启动 MySQL 8 容器(root 强密码,外部端口 13306,数据落盘 /www/live-data/mysql) +# 3. 启动 Redis 7 容器(强密码,仅容器网络内可访问 + 本机 127.0.0.1:16379 调试口) +# 4. 生成宿主机 nginx 站点配置(80/443 -> 容器端口 9501/9502/9503,自签证书启用 https, +# 并清理 nginx.conf / conf.d 中旧 live 站点的反代配置) +# 5. 自动生成后端配置 /www/wwwroot/live.fuhua91.com/config/config.yaml(写入生成的密码) +# 6. 凭据保存到 /root/live-credentials.txt(chmod 600) +# +# 用法: +# bash server-init.sh 仅初始化基础环境 +# bash server-init.sh latest 初始化完成后立即用 deploy.sh 部署 latest 版本 +# HARBOR_PASSWORD='xxx' bash server-init.sh latest 连 Harbor 密码一起传给 deploy.sh +# +# 建议: 放到 /root/server-init.sh,执行 chmod 700 /root/server-init.sh +# ============================================================ +set -euo pipefail + +# ---------- 可按需调整的参数 ---------- +MYSQL_CONTAINER="live-mysql" +REDIS_CONTAINER="live-redis" +NETWORK="live-net" + +MYSQL_IMAGE="mysql:8.0" +REDIS_IMAGE="redis:7-alpine" + +DATA_DIR="/www/live-data" # MySQL/Redis/网关配置/证书等落盘目录 + +MYSQL_EXTERNAL_PORT=13306 # MySQL 对外端口 +REDIS_LOCAL_PORT=16379 # Redis 仅绑定 127.0.0.1 的调试端口,不对公网开放 + +LIVE_HOST="${KRA_LIVE_HOST:-live.yuzou99.com}" +HOST_NGINX_CONF="/etc/nginx/conf.d/live.fuhua91.com.conf" # 宿主机 nginx 站点配置 +SSL_WEBROOT="$DATA_DIR/ssl/webroot" # acme.sh http-01 验证目录 +SSL_CERT_DIR="$DATA_DIR/ssl/certs" # HTTPS 证书目录 + +BASE_DIR="/www/wwwroot/live.fuhua91.com" +CONFIG_DIR="$BASE_DIR/config" # 后端配置目录 -> 容器内 /data/conf +CRED_FILE="/root/live-credentials.txt" +# Docker Hub 镜像加速(拉不动 mysql/redis 时 docker 会自动回退官方源; +# 如你的服务器可直接访问 Docker Hub,可清空此数组) +DOCKER_MIRRORS=("https://docker.1ms.run" "https://docker.m.daocloud.io") +# -------------------------------------- + +TAG="${1:-}" + +# ---------- 通用 ---------- +if [ "$(id -u)" -ne 0 ]; then + echo "错误: 请用 root 执行本脚本" + exit 1 +fi + +# 生成 24 位强密码(字符集避开 MySQL DSN 分隔符 @ : / ? 以及 shell/YAML 敏感字符) +gen_pass() { + local pass="" + while [ "${#pass}" -lt 24 ]; do + pass="${pass}$(tr -dc 'A-Za-z0-9#$%^*+_-' < /dev/urandom | head -c 32 || true)" + done + printf '%s' "${pass:0:24}" +} + +# ---------- 1. 安装 Docker ---------- +if [ -r /etc/os-release ]; then + . /etc/os-release + OS_ID="${ID:-unknown}" + OS_MAJOR="${VERSION_ID%%.*}" +else + OS_ID="unknown" + OS_MAJOR="" +fi + +fix_centos7_repos() { + echo "==> 检测到 CentOS 7(已 EOL),切换 yum 源到 vault" + mkdir -p /etc/yum.repos.d/backup + for f in /etc/yum.repos.d/CentOS-*.repo; do + [ -e "$f" ] || return 0 + mv "$f" /etc/yum.repos.d/backup/ 2>/dev/null || true + done + curl -fsSL -o /etc/yum.repos.d/CentOS-Base.repo \ + https://mirrors.aliyun.com/repo/Centos-7.repo 2>/dev/null || true + yum clean all >/dev/null 2>&1 || true +} + +install_docker_rhel() { + yum install -y yum-utils 2>/dev/null || yum install -y --allowerasing yum-utils + if ! yum-config-manager --add-repo \ + https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo; then + yum-config-manager --add-repo \ + https://download.docker.com/linux/centos/docker-ce.repo + fi + yum makecache fast >/dev/null 2>&1 || yum makecache >/dev/null 2>&1 || true + yum install -y docker-ce docker-ce-cli containerd.io \ + || yum install -y --allowerasing docker-ce docker-ce-cli containerd.io +} + +install_docker_deb() { + local distro="$1" codename + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y ca-certificates curl gnupg lsb-release + codename="$(lsb_release -cs 2>/dev/null || echo stable)" + install -m 0755 -d /etc/apt/keyrings + curl -fsSL "https://mirrors.aliyun.com/docker-ce/linux/${distro}/gpg" \ + | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ +https://mirrors.aliyun.com/docker-ce/linux/${distro} ${codename} stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -y + apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin +} + +configure_docker() { + # 日志轮转 + 镜像加速,避免容器日志撑爆磁盘 + [ -f /etc/docker/daemon.json ] && cp /etc/docker/daemon.json "/etc/docker/daemon.json.bak.$(date +%s)" || true + local mirrors_json="[]" + if [ "${#DOCKER_MIRRORS[@]}" -gt 0 ]; then + mirrors_json=$(printf '"%s",' "${DOCKER_MIRRORS[@]}") + mirrors_json="[${mirrors_json%,}]" + fi + cat > /etc/docker/daemon.json </dev/null 2>&1; then + echo "==> Docker 已安装: $(docker --version),跳过安装" + configure_docker + return 0 + fi + echo "==> 安装 Docker(系统: ${OS_ID} ${OS_MAJOR:-})" + case "$OS_ID" in + centos|rhel|rocky|almalinux|anolis|opencloudos|tencentos) + [ "$OS_MAJOR" = "7" ] && fix_centos7_repos + install_docker_rhel + ;; + ubuntu|debian) + install_docker_deb "$OS_ID" + ;; + *) + echo "错误: 未识别的系统 ${OS_ID},请手动安装 Docker 后重新执行本脚本" + exit 1 + ;; + esac + systemctl enable --now docker + configure_docker + echo "==> Docker 安装完成: $(docker --version)" +} + +# ---------- 2. 读取/生成凭据 ---------- +MYSQL_ROOT_PASSWORD="" +REDIS_PASSWORD="" +if [ -f "$CRED_FILE" ]; then + MYSQL_ROOT_PASSWORD="$(sed -n "s/^MYSQL_ROOT_PASSWORD='\(.*\)'$/\1/p" "$CRED_FILE")" + REDIS_PASSWORD="$(sed -n "s/^REDIS_PASSWORD='\(.*\)'$/\1/p" "$CRED_FILE")" +fi +if [ -z "$MYSQL_ROOT_PASSWORD" ]; then + MYSQL_ROOT_PASSWORD="$(gen_pass)" +fi +if [ -z "$REDIS_PASSWORD" ]; then + REDIS_PASSWORD="$(gen_pass)" +fi + +save_credentials() { + cat > "$CRED_FILE" </dev/null 2>&1 || true + + if docker ps --format '{{.Names}}' | grep -qx "$MYSQL_CONTAINER"; then + echo "==> $MYSQL_CONTAINER 已在运行,跳过" + return 0 + fi + # 容器存在但停止:直接启动(数据卷里已有旧密码,不能重置环境变量) + if docker ps -a --format '{{.Names}}' | grep -qx "$MYSQL_CONTAINER"; then + echo "==> 启动已存在的 $MYSQL_CONTAINER" + docker start "$MYSQL_CONTAINER" + return 0 + fi + + echo "==> 首次启动 $MYSQL_CONTAINER($MYSQL_IMAGE,外部端口 $MYSQL_EXTERNAL_PORT)" + mkdir -p "$DATA_DIR/mysql" + docker run -d --name "$MYSQL_CONTAINER" \ + --restart always \ + --network "$NETWORK" \ + -p "${MYSQL_EXTERNAL_PORT}:3306" \ + -e TZ=Asia/Shanghai \ + -e MYSQL_ROOT_PASSWORD="$MYSQL_ROOT_PASSWORD" \ + -e MYSQL_DATABASE=yzyzlive \ + -v "$DATA_DIR/mysql:/var/lib/mysql" \ + "$MYSQL_IMAGE" \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_general_ci \ + --default-authentication-plugin=mysql_native_password + + echo "==> 等待 MySQL 就绪(首次启动约 20-60 秒)" + local i ready=0 + for i in $(seq 1 60); do + if docker exec "$MYSQL_CONTAINER" mysqladmin ping -uroot -p"$MYSQL_ROOT_PASSWORD" --silent >/dev/null 2>&1; then + ready=1 + break + fi + sleep 3 + done + if [ "$ready" -ne 1 ]; then + echo "错误: MySQL 未就绪,请执行 docker logs $MYSQL_CONTAINER 排查" + exit 1 + fi + echo "==> MySQL 就绪(数据库 yzyzlive 已创建)" +} + +# ---------- 4. Redis ---------- +run_redis() { + if docker ps --format '{{.Names}}' | grep -qx "$REDIS_CONTAINER"; then + echo "==> $REDIS_CONTAINER 已在运行,跳过" + return 0 + fi + if docker ps -a --format '{{.Names}}' | grep -qx "$REDIS_CONTAINER"; then + echo "==> 启动已存在的 $REDIS_CONTAINER" + docker start "$REDIS_CONTAINER" + return 0 + fi + + echo "==> 首次启动 $REDIS_CONTAINER($REDIS_IMAGE,不对公网开放)" + mkdir -p "$DATA_DIR/redis" + docker run -d --name "$REDIS_CONTAINER" \ + --restart always \ + --network "$NETWORK" \ + -p "127.0.0.1:${REDIS_LOCAL_PORT}:6379" \ + -e TZ=Asia/Shanghai \ + -v "$DATA_DIR/redis:/data" \ + "$REDIS_IMAGE" \ + redis-server --requirepass "$REDIS_PASSWORD" --appendonly yes + + local i + for i in $(seq 1 10); do + if docker exec "$REDIS_CONTAINER" redis-cli -a "$REDIS_PASSWORD" --no-auth-warning ping \ + 2>/dev/null | grep -q PONG; then + echo "==> Redis 就绪(本机调试口 127.0.0.1:${REDIS_LOCAL_PORT})" + return 0 + fi + sleep 2 + done + echo "错误: Redis 启动异常,请执行 docker logs $REDIS_CONTAINER 排查" + exit 1 +} + +# ---------- 4.5 宿主机 nginx 站点配置(80/443 -> 容器端口 9501/9502/9503) ---------- +# 清理 nginx.conf 及 conf.d 中旧站点(live.fuhua91.com / live.yuzhou99.com / live.yuzou99.com / test-zb.yzyzzb.com)的反代配置(整块删除含目标域名的 server 块) +clean_old_nginx_proxy() { + local f=/etc/nginx/nginx.conf + if [ -f "$f" ] && grep -qE 'live\.(fuhua91|yuzhou99|yuzou99)\.com|test-zb\.yzyzzb\.com' "$f"; then + cp "$f" "$f.bak.$(date +%s)" + awk ' + BEGIN { in_server=0; depth=0 } + !in_server { + if ($0 ~ /^[[:space:]]*server[[:space:]]*\{/) { + in_server=1; depth=1; buf=$0 "\n"; next + } + print; next + } + in_server { + buf = buf $0 "\n" + o = gsub(/\{/, "{"); c = gsub(/\}/, "}") + depth += o - c + if (depth <= 0) { + if (buf !~ /live\.(fuhua91|yuzhou99|yuzou99)\.com|test-zb\.yzyzzb\.com/) printf "%s", buf + in_server=0; buf="" + } + } + ' "$f" > "$f.tmp" && mv "$f.tmp" "$f" + echo "==> 已从 nginx.conf 移除旧 live 站点反代配置(备份: $f.bak.*)" + fi + local cf my_conf my_ssl_conf + my_conf="$HOST_NGINX_CONF" + my_ssl_conf="${HOST_NGINX_CONF%.conf}.ssl.conf" + for cf in /etc/nginx/conf.d/*.conf; do + [ -e "$cf" ] || continue + [ "$cf" = "$my_conf" ] && continue + [ "$cf" = "$my_ssl_conf" ] && continue + if grep -qE 'live\.(fuhua91|yuzhou99|yuzou99)\.com|test-zb\.yzyzzb\.com' "$cf"; then + mv "$cf" "$cf.bak.$(date +%s)" + echo "==> 已移除旧站点配置: $cf(备份为 .bak.*)" + fi + done +} + +write_host_nginx_conf() { + # 清理旧方案的网关容器(如曾创建) + docker rm -f live-gateway >/dev/null 2>&1 || true + mkdir -p "$SSL_WEBROOT" "$SSL_CERT_DIR" + + if ! command -v nginx >/dev/null 2>&1; then + echo "警告: 宿主机未安装 nginx,跳过站点配置。请安装后重新执行本脚本:" + if command -v apt-get >/dev/null 2>&1; then + echo " apt-get install -y nginx" + else + echo " yum install -y nginx && systemctl enable --now nginx" + fi + return 0 + fi + + # 移除 nginx.conf / conf.d 中旧 live 站点的反代配置,避免冲突 + clean_old_nginx_proxy + + # 确保 nginx.conf include 了 conf.d/*.conf(旧反代直接写在 nginx.conf 的机器常缺这条) + if ! grep -Eq 'include[[:space:]]+.*conf\.d/\*\.conf' /etc/nginx/nginx.conf; then + mkdir -p /etc/nginx/conf.d + if grep -qE '^[[:space:]]*http[[:space:]]*\{' /etc/nginx/nginx.conf; then + cp /etc/nginx/nginx.conf "/etc/nginx/nginx.conf.bak.$(date +%s)" + sed -i '/^[[:space:]]*http[[:space:]]*\{/a\ include /etc/nginx/conf.d/*.conf;' /etc/nginx/nginx.conf + echo "==> 已在 nginx.conf 的 http {} 中加入 include /etc/nginx/conf.d/*.conf" + else + echo "警告: nginx.conf 中未找到 http {} 块,请手动加入 include conf.d/*.conf 后重载" + fi + fi + + [ -f "$HOST_NGINX_CONF" ] && cp "$HOST_NGINX_CONF" "$HOST_NGINX_CONF.bak.$(date +%s)" + + echo "==> 写入宿主机 nginx 站点配置: $HOST_NGINX_CONF" + cat > "$HOST_NGINX_CONF" <<'NGINXCONF' +# live.fuhua91.com 站点配置(server-init.sh 生成;HTTPS 模板见 live.fuhua91.com.ssl.conf.example) +# 应用容器端口(deploy.sh 映射): admin=9501, 后端=9502, 主站=9503 + +# 变量名带 live_ 前缀,避免与宿主机 nginx 其他配置中的 map 重名冲突 +map $http_upgrade $live_connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80 default_server; + server_name live.fuhua91.com; + + # 启用 HTTPS 后取消下行注释(acme 续期会经 301 走 https 验证,仍可用) + #return 301 https://$host$request_uri; + + # admin 媒体上传,默认 1m 会挡上传 + client_max_body_size 512m; + + # CORS 全放行:反射 Origin(兼容带 cookie 的请求,* 与 credentials 不兼容) + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS' always; + add_header Access-Control-Allow-Headers $http_access_control_request_headers always; + add_header Vary Origin always; + + # 隐藏后端自带的 CORS header,避免与上面重复导致浏览器报错 + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + + # 预检请求直接响应,不转发后端(if 块的 add_header 会覆盖上面的,需写全) + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS' always; + add_header Access-Control-Allow-Headers $http_access_control_request_headers always; + add_header Access-Control-Max-Age 86400 always; + add_header Vary Origin always; + return 204; + } + + # acme.sh http-01 验证 + location ^~ /.well-known/acme-challenge/ { + root /www/live-data/ssl/webroot; + } + + # 后端 API / WebSocket(去掉 /api 前缀转给后端容器 9502) + location = /api { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $live_connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:9502; + } + location ^~ /api/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $live_connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:9502; + } + + # admin 管理后台(去掉 /admin 前缀转给 admin 前端容器 9501) + location = /admin { + return 301 /admin/; + } + location ^~ /admin/ { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + rewrite ^/admin/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:9501; + } + + # 主站直播页(live-web 容器 9503) + location / { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:9503; + } +} +NGINXCONF + + # HTTPS 模板(.ssl.conf.example 后缀不会被 *.conf include,复制为 .ssl.conf 后生效) + echo "==> 写入 HTTPS 配置模板: ${HOST_NGINX_CONF%.conf}.ssl.conf.example" + cat > "${HOST_NGINX_CONF%.conf}.ssl.conf.example" <<'SSLEX' +# 启用 HTTPS 三步: +# 1. 证书安装到 /www/live-data/ssl/certs/{fullchain.pem,privkey.pem} +# 2. cp live.fuhua91.com.ssl.conf.example live.fuhua91.com.ssl.conf +# 3. nginx -t && systemctl reload nginx +# 4. 在 live.fuhua91.com.conf 打开 return 301 注释后再 reload 一次 + +server { + listen 443 ssl http2 default_server; + server_name live.fuhua91.com; + + ssl_certificate /www/live-data/ssl/certs/fullchain.pem; + ssl_certificate_key /www/live-data/ssl/certs/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + client_max_body_size 512m; + + # CORS 全放行(同 80 端口 server 块) + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS' always; + add_header Access-Control-Allow-Headers $http_access_control_request_headers always; + add_header Vary Origin always; + + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS' always; + add_header Access-Control-Allow-Headers $http_access_control_request_headers always; + add_header Access-Control-Max-Age 86400 always; + add_header Vary Origin always; + return 204; + } + + # acme.sh http-01 验证(续期会经 301 走 https 验证,仍可用) + location ^~ /.well-known/acme-challenge/ { + root /www/live-data/ssl/webroot; + } + + location = /api { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $live_connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:9502; + } + location ^~ /api/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $live_connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:9502; + } + + location = /admin { + return 301 /admin/; + } + location ^~ /admin/ { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + rewrite ^/admin/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:9501; + } + + location / { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:9503; + } +} +SSLEX + + # 域名统一替换(默认 live.yuzou99.com,可用 KRA_LIVE_HOST 覆盖) + sed -i "s/live\.fuhua91\.com/${LIVE_HOST}/g" "$HOST_NGINX_CONF" + sed -i "s/live\.fuhua91\.com/${LIVE_HOST}/g" "${HOST_NGINX_CONF%.conf}.ssl.conf.example" + + # 自签证书(已存在则复用;浏览器提示不安全属正常,正式证书可后补) + if [ ! -f "$SSL_CERT_DIR/fullchain.pem" ]; then + echo "==> 生成自签证书: $LIVE_HOST(10 年有效期)" + openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \ + -keyout "$SSL_CERT_DIR/privkey.pem" -out "$SSL_CERT_DIR/fullchain.pem" \ + -subj "/CN=${LIVE_HOST}" \ + -addext "subjectAltName=DNS:${LIVE_HOST}" 2>/dev/null \ + || openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \ + -keyout "$SSL_CERT_DIR/privkey.pem" -out "$SSL_CERT_DIR/fullchain.pem" \ + -subj "/CN=${LIVE_HOST}" + chmod 600 "$SSL_CERT_DIR/privkey.pem" + fi + + # 启用 443(自签证书)+ 80 强跳 https + cp -f "${HOST_NGINX_CONF%.conf}.ssl.conf.example" "${HOST_NGINX_CONF%.conf}.ssl.conf" + sed -i 's|^ #return 301 https://| return 301 https://|' "$HOST_NGINX_CONF" + + # SELinux 开启时允许 nginx 反代网络端口(CentOS/Rocky 常见 502 原因) + if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null)" = "Enforcing" ]; then + setsebool -P httpd_can_network_connect 1 2>/dev/null \ + || echo "警告: setsebool 失败,nginx 反代容器端口可能 502" + fi + + if nginx -t 2>/dev/null; then + systemctl enable nginx >/dev/null 2>&1 || true + if systemctl is-active --quiet nginx; then + systemctl reload nginx 2>/dev/null || nginx -s reload 2>/dev/null || true + else + systemctl start nginx 2>/dev/null || true + fi + echo "==> nginx 配置已生效(80/443 -> 9503 主站 / 9501 admin / 9502 api,https 为自签证书)" + else + echo "警告: nginx -t 校验未通过,请手动检查后重载:" + nginx -t || true + echo " nginx -t && systemctl reload nginx" + fi +} +# ---------- 5. 生成后端配置 ---------- +write_backend_config() { + mkdir -p "$CONFIG_DIR" + if [ ! -f "$CONFIG_DIR/config.yaml" ]; then + echo "==> 生成后端配置: $CONFIG_DIR/config.yaml" + cat > "$CONFIG_DIR/config.yaml" <<'CONF' +server: + http: + network: tcp + addr: 0.0.0.0:8000 + timeout: 600s +data: + database: + driver: mysql + source: "" + host: live-mysql + port: "3306" + user: root + password: '__MYSQL_ROOT_PASSWORD__' + name: yzyzlive + config: charset=utf8mb4&parseTime=True&loc=Local + path: "" + alias_name: "" + disable: false + prefix: "" + engine: InnoDB + log_mode: info + max_idle_conns: 10 + max_open_conns: 100 + conn_max_lifetime: 3600 + singular: false + redis: + network: tcp + name: default + addr: live-redis:6379 + password: '__REDIS_PASSWORD__' + db: 0 + use_cluster: false + cluster_addrs: [] + read_timeout: 0.200s + write_timeout: 0.200s + database_list: [] + redis_list: [] + mongo: + coll: "" + options: "" + database: "" + username: "" + password: "" + auth_source: "" + min_pool_size: "0" + max_pool_size: "100" + socket_timeout_ms: "0" + connect_timeout_ms: "0" + is_zap: false + hosts: + - host: "" + port: "" +admin: + router_prefix: "" + jwt: + signing_key: '__JWT_SIGNING_KEY__' + expires_time: 604800s + buffer_time: 86400s + issuer: kra + captcha: + key_long: 6 + img_width: 240 + img_height: 80 + store_expiration: 180s + local: + store_path: uploads/file + path_prefix: uploads/file + media: + session_ttl: 24 + max_file_size: "0" + chunk_dir: uploads/chunks + system: + use_redis: false + use_multipoint: false + use_strict_auth: false + disable_auto_migrate: false + use_mongo: false + addr: 8000 + iplimit_count: 0 + iplimit_time: 0 + zap: + level: info + prefix: '[kra] ' + format: json + director: logs + encode_level: LowercaseLevelEncoder + stacktrace_key: stacktrace + show_line: true + log_in_console: true + retention_day: 7 + access_req_body: true + access_resp_data: true + access_req_headers: true + access_log_max_bytes: 1024 + file_only_modules: + - http + - sql + cors: + mode: allow-all + whitelist: [] + app: + node: "" + app_id: kra + env: production + disk_list: + - mount_point: / +CONF + sed -i "s|__MYSQL_ROOT_PASSWORD__|${MYSQL_ROOT_PASSWORD}|; s|__REDIS_PASSWORD__|${REDIS_PASSWORD}|; s|__JWT_SIGNING_KEY__|$(gen_pass)|" \ + "$CONFIG_DIR/config.yaml" +else + echo "==> 后端配置已存在,跳过生成: $CONFIG_DIR/config.yaml" +fi + # 容器内进程以 uid 1000 (kra) 运行: + # - 配置文件必须对其可读,否则 panic: permission denied + # - 配置目录必须对其可写,否则首装向导无法把生成的 JWT signing_key 写回文件 + chown -R 1000:1000 "$CONFIG_DIR" + chmod 600 "$CONFIG_DIR/config.yaml" +} + +# ---------- 6. 放行防火墙端口(尽力而为) ---------- +open_firewall() { + local ports=("13306/tcp" "80/tcp" "443/tcp" "9504/udp") + echo "==> 尝试放行防火墙端口 ${ports[*]}(云安全组需自行放行)" + local p + for p in "${ports[@]}"; do + if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then + firewall-cmd --permanent --add-port="${p}" >/dev/null 2>&1 || true + firewall-cmd --reload >/dev/null 2>&1 || true + fi + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + ufw allow "${p}" >/dev/null 2>&1 || true + fi + done +} + +# ---------- 主流程 ---------- +install_docker +save_credentials +run_mysql +run_redis +write_backend_config +write_host_nginx_conf +open_firewall + +echo "" +echo "============================================================" +echo " 基础环境初始化完成" +echo "============================================================" +echo " MySQL: live-mysql 容器, 外部端口 $MYSQL_EXTERNAL_PORT, 数据目录 $DATA_DIR/mysql" +echo " Redis: live-redis 容器, 仅容器网络 + 127.0.0.1:$REDIS_LOCAL_PORT" +echo " nginx: 宿主机 nginx 站点配置 -> 主站(9503)/admin(9501)/api(9502)" +echo " HTTPS: 已用自签证书启用($LIVE_HOST),浏览器提示不安全属正常" +echo " 后端配置: $CONFIG_DIR/config.yaml" +echo " 凭据文件: $CRED_FILE (MySQL root / Redis 密码都在里面)" +echo "" +echo " 云服务器安全组请放行: $MYSQL_EXTERNAL_PORT/tcp, 80/tcp, 443/tcp, 9504/udp(WebRTC)" +echo " 请确认 DNS: $LIVE_HOST 已解析到本机公网 IP" +echo "" +echo " 换正式证书(可选,替换自签证书,同路径自动 reload):" +echo " curl https://get.acme.sh | sh -s email=you@example.com" +echo " ~/.acme.sh/acme.sh --issue -d $LIVE_HOST -w $SSL_WEBROOT --server letsencrypt" +echo " ~/.acme.sh/acme.sh --install-cert -d $LIVE_HOST \\" +echo " --fullchain-file $SSL_CERT_DIR/fullchain.pem \\" +echo " --key-file $SSL_CERT_DIR/privkey.pem \\" +echo " --reloadcmd 'nginx -s reload'" +echo "" +if [ -n "$TAG" ]; then + echo "==> 继续部署应用版本: $TAG" + DEPLOY_SH="$(cd "$(dirname "$0")" && pwd)/deploy.sh" + [ -f "$DEPLOY_SH" ] || DEPLOY_SH="/root/deploy.sh" + if [ -f "$DEPLOY_SH" ]; then + bash "$DEPLOY_SH" "$TAG" + else + echo "错误: 找不到 deploy.sh(与 server-init.sh 同目录或 /root/deploy.sh),请手动执行部署" + exit 1 + fi +else + echo " 下一步: 上传 deploy.sh 到服务器后执行(Harbor 需先登录):" + echo " docker login harbor.fuhua91.com -u 'robot\$live+live'" + echo " bash /root/deploy.sh " +fi diff --git a/internal/integration/payment/wechat_v3.go b/internal/integration/payment/wechat_v3.go index 1f824bc..983a20a 100644 --- a/internal/integration/payment/wechat_v3.go +++ b/internal/integration/payment/wechat_v3.go @@ -39,15 +39,23 @@ func (a *wechatV3Adapter) Create(ctx context.Context, req *bizpayment.PaymentReq bm.SetBodyMap("amount", func(amount gopay.BodyMap) { amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency)) }) - mergeGoPayExtras(bm, req.Extra, - "appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer", - "trade_type", "pay_type", "method", "openid", "open_id", "auth_code", "authcode", "barcode", - ) - tradeType := normalizeWechatV3TradeType(firstAny(req.Extra, "trade_type", "pay_type", "method")) if tradeType == "" { tradeType = normalizeWechatV3TradeType(firstAny(c, "trade_type", "pay_type", "method")) } + if tradeType == "native" || tradeType == "qr" || tradeType == "qrcode" { + // Native orders only accept the fields defined by the WeChat transaction API. + for _, key := range []string{"time_expire", "attach", "goods_tag", "support_fapiao", "detail", "scene_info", "settle_info"} { + if value, ok := req.Extra[key]; ok { + bm[key] = value + } + } + } else { + mergeGoPayExtras(bm, req.Extra, + "appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer", + "trade_type", "pay_type", "method", "openid", "open_id", "auth_code", "authcode", "barcode", + ) + } switch tradeType { case "", "jsapi", "js_api", "mini", "miniapp", "mini_app", "miniprogram", "mini_program", "applet": if openID := firstAny(req.Extra, "openid", "open_id"); openID != "" { diff --git a/internal/integration/payment/wechat_v3_native_test.go b/internal/integration/payment/wechat_v3_native_test.go new file mode 100644 index 0000000..3e91ab2 --- /dev/null +++ b/internal/integration/payment/wechat_v3_native_test.go @@ -0,0 +1,87 @@ +package payment + +import ( + "context" + "encoding/json" + "io" + bizpayment "kra/internal/biz/payment" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + gopayWechatV3 "github.com/go-pay/gopay/wechat/v3" +) + +func TestWechatV3NativeOnlySendsDocumentedFields(t *testing.T) { + platformKey, _, platformPublicPEM := testRSAKeyPair(t) + _, merchantPrivatePEM, _ := testRSAKeyPair(t) + const ( + platformSerial = "PLATFORM-SERIAL" + timestamp = "1787068800" + nonce = "native-response-nonce" + ) + response := []byte(`{"code_url":"weixin://wxpay/bizpayurl?pr=test"}`) + signature := testRSA2Sign(t, platformKey, []byte(timestamp+"\n"+nonce+"\n"+string(response)+"\n")) + + var requestBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v3/pay/transactions/native" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request: %v", err) + return + } + if err = json.Unmarshal(body, &requestBody); err != nil { + t.Errorf("decode request: %v", err) + return + } + w.Header().Set(gopayWechatV3.HeaderTimestamp, timestamp) + w.Header().Set(gopayWechatV3.HeaderNonce, nonce) + w.Header().Set(gopayWechatV3.HeaderSerial, platformSerial) + w.Header().Set(gopayWechatV3.HeaderSignature, signature) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(response) + })) + defer server.Close() + + result, err := (&wechatV3Adapter{}).Create(context.Background(), &bizpayment.PaymentRequest{ + Provider: bizpayment.PaymentWechatV3, + TradeNo: "T-WECHAT-NATIVE", + Subject: "subject", + Amount: 123, + Currency: "CNY", + NotifyURL: "https://merchant.test/payment/callback", + Extra: map[string]any{ + "method": "qrcode", "pay_method": "native", "product_id": "item-1", + "payer": map[string]any{"openid": "not-for-native"}, + "attach": "order-meta", "scene_info": map[string]any{"payer_client_ip": "127.0.0.1"}, + }, + }, map[string]any{ + "app_id": "wx-test", + "merchant_id": "mch-test", + "serial_no": "merchant-serial", + "private_key": merchantPrivatePEM, + "api_v3_key": "01234567890123456789012345678901", + "platform_cert": platformPublicPEM, + "platform_serial_no": platformSerial, + "create_url": server.URL, + }) + if err != nil { + t.Fatal(err) + } + want := map[string]any{ + "appid": "wx-test", "mchid": "mch-test", "description": "subject", + "out_trade_no": "T-WECHAT-NATIVE", "notify_url": "https://merchant.test/payment/callback", + "amount": map[string]any{"total": float64(123), "currency": "CNY"}, + "attach": "order-meta", "scene_info": map[string]any{"payer_client_ip": "127.0.0.1"}, + } + if !reflect.DeepEqual(requestBody, want) { + t.Fatalf("native request = %#v, want %#v", requestBody, want) + } + if result.Status != "created" || result.TradeNo != "T-WECHAT-NATIVE" || string(result.Payload) != string(response) { + t.Fatalf("native result = %+v", result) + } +}