Compare commits
21 Commits
client-v1.2.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ceef18ace | |||
| a399b3d701 | |||
| e957894766 | |||
| a4c51203fb | |||
| 03f3c9228f | |||
| 2a7eec02fb | |||
| a392d18a6a | |||
| a913fe56fb | |||
| 18fb5177be | |||
| 80d88f185d | |||
| cd1ef98b27 | |||
| b087b5afc8 | |||
| 6a3cb48a02 | |||
| c3736fa43a | |||
| 6c6d9636bb | |||
| 3f54efa078 | |||
| 8f0c2ef81b | |||
| d094a53d7e | |||
| c152c2aadf | |||
| cf3b806311 | |||
| f1e527918f |
@@ -6,20 +6,14 @@ name: Deploy Client
|
||||
# intentionally DECOUPLED — see the "why build-macos/build-ios don't block"
|
||||
# note above the build-macos job below for the mechanism and rationale.
|
||||
#
|
||||
# TODO(controller) — RUNNER AVAILABILITY: per docs/ci-runner.md, pangolin
|
||||
# currently has exactly ONE registered Gitea Actions runner
|
||||
# ("mac-pangolin-2", label `nas:host`). Neither `runs-on: mac` nor
|
||||
# `runs-on: windows` below has any runner registered to pick it up yet — this
|
||||
# workflow will queue forever until that's fixed. Options: (a) register
|
||||
# mac-pangolin-2 with an additional `mac` label (it's already a mac host —
|
||||
# cheapest fix for build-android/release-deploy) and separately stand up +
|
||||
# register an actual Windows host runner labeled `windows` for build-windows
|
||||
# (no such machine exists per docs/ci-runner.md), or (b) repoint both at
|
||||
# `nas` and accept that Android/Windows builds then compete with the
|
||||
# docker-in-domain nas jobs on the same single mac host. This mirrors the
|
||||
# `runs-on: mac` / `runs-on: windows` split already planned in
|
||||
# docs/superpowers/plans/2026-07-05-cicd.md Task 7/10 — written that way here
|
||||
# for fidelity to that plan, NOT because the runners are confirmed to exist.
|
||||
# RUNNER 就绪(2026-09-07 起,过时 TODO 已删):用户级 mac runner(label `mac`)+
|
||||
# windows runner(label `windows`)均已注册(单一真相源见 baize ci_runners)。
|
||||
# runs-on: mac / windows 正常派工,不再无限排队。
|
||||
#
|
||||
# WEB 已并入本工作流(deploy-web job,构建 usercenter+website → Cloudflare Pages)——
|
||||
# 一个 `client-v*` tag 即发 **web + iOS/mac/Android/Windows**(对齐 jiu 的
|
||||
# deploy-client)。仅发 web 仍可用独立的 site-v* / deploy-site.yml(逻辑同源,共用
|
||||
# scripts/ci/{compile-site,compile-usercenter,combine-site,deploy-site}.sh)。
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -46,6 +40,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 浅克隆:走 ali frps 隧道时数据量从全历史降到一层,大幅降低 Checkout 超时率
|
||||
# (外网/在家都受益;根治外网稳定见 fetch 重试 + frps 调优)。
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Compile (Android APK)
|
||||
env:
|
||||
@@ -77,6 +75,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 浅克隆:走 ali frps 隧道时数据量从全历史降到一层,大幅降低 Checkout 超时率
|
||||
# (外网/在家都受益;根治外网稳定见 fetch 重试 + frps 调优)。
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Compile (Windows installer)
|
||||
shell: bash
|
||||
@@ -102,6 +104,13 @@ jobs:
|
||||
# APPSTORE_API_* 等),macos/ios 正常应成功;continue-on-error 只是抖动兜底。
|
||||
build-macos:
|
||||
runs-on: mac
|
||||
# 串行化 mac 三 job(android→macos→ios):家里只有一个 mac runner(capacity=1),
|
||||
# 三个 runs-on:mac 并行会抢同一 runner → 抢不到的被取消(run#286 的 macos/ios 全步
|
||||
# 骤 cancelled 即此)。用 needs 串成链让它们一个跑完再跑下一个;`if: !cancelled()`
|
||||
# 保证即便上游(android/macos)失败,本 job 仍在其完成后照跑(不因上游 fail 被 skip),
|
||||
# 与 continue-on-error 一起兜住「单平台抖动不拖累其他平台」。对齐 jiu 的 needs 链。
|
||||
needs: [build-android]
|
||||
if: ${{ !cancelled() }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
@@ -112,6 +121,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 浅克隆:走 ali frps 隧道时数据量从全历史降到一层,大幅降低 Checkout 超时率
|
||||
# (外网/在家都受益;根治外网稳定见 fetch 重试 + frps 调优)。
|
||||
fetch-depth: 1
|
||||
|
||||
# 多账号切换(见 docs/ci-multi-account-signing-design.html):从 signing.env
|
||||
# 单源读 SIGNING_ACCOUNT(us|cn),下方按它选账号的证书/ASC 密钥集。
|
||||
@@ -144,6 +157,9 @@ jobs:
|
||||
|
||||
build-ios:
|
||||
runs-on: mac
|
||||
# 串行链末环:等 build-macos 完成再跑(单 mac runner 不争用,见 build-macos 注释)。
|
||||
needs: [build-macos]
|
||||
if: ${{ !cancelled() }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
@@ -154,6 +170,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 浅克隆:走 ali frps 隧道时数据量从全历史降到一层,大幅降低 Checkout 超时率
|
||||
# (外网/在家都受益;根治外网稳定见 fetch 重试 + frps 调优)。
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Resolve signing account
|
||||
id: acct
|
||||
@@ -177,6 +197,34 @@ jobs:
|
||||
# No artifact upload — compile-ios.sh uploads straight to TestFlight via
|
||||
# altool (matches jiu); nothing is produced under dist/ for this job.
|
||||
|
||||
# WEB(用户中心 usercenter + 官网 website → Cloudflare Pages):并入本工作流,使
|
||||
# 一个 client-v* tag 同时发 web + 四客户端平台(对齐 jiu 的 build-client-web)。
|
||||
# 与 deploy-site.yml 同源(共用 scripts/ci/{compile-site,compile-usercenter,
|
||||
# combine-site,deploy-site}.sh);web 直接自部署到 CF Pages,不产 dist/ 产物、
|
||||
# 不进 release-deploy 的 artifact 下载。ubuntu-latest = 家里 nas act_runner。
|
||||
deploy-web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 浅克隆:走 ali frps 隧道时数据量从全历史降到一层,大幅降低 Checkout 超时率
|
||||
# (外网/在家都受益;根治外网稳定见 fetch 重试 + frps 调优)。
|
||||
fetch-depth: 1
|
||||
- name: Compile (Astro 官网)
|
||||
env:
|
||||
SITE_URL: https://pangolin.yanmeiai.com
|
||||
run: bash scripts/ci/compile-site.sh
|
||||
- name: Compile (用户中心 Next.js)
|
||||
run: bash scripts/ci/compile-usercenter.sh
|
||||
- name: Combine (官网 + 用户中心)
|
||||
run: bash scripts/ci/combine-site.sh
|
||||
- name: Deploy → Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: bash scripts/ci/deploy-site.sh
|
||||
|
||||
# release-deploy 发布策略:needs 全部四平台(等它们**完成**,不抢跑),但用
|
||||
# `if` 让 mac/ios/windows 的失败不 skip 本 job —— 只要 android(稳定 floor)成功即发。
|
||||
# 背景:build-macos/ios 必须跑在 mac runner,其 Checkout(git fetch git.51yanmei.com)
|
||||
@@ -187,7 +235,7 @@ jobs:
|
||||
# · continue-on-error 在 gitea 里**不**让下游 needs 在失败时继续(会 skip),故必须
|
||||
# 配 `if: always() && needs.build-android.result=='success'` 才能「等全部、失败不阻」。
|
||||
release-deploy:
|
||||
needs: [build-android, build-windows, build-macos, build-ios]
|
||||
needs: [build-android, build-windows, build-macos, build-ios, deploy-web]
|
||||
if: ${{ always() && needs.build-android.result == 'success' }}
|
||||
# ubuntu-latest = 家里 nas act_runner,与 gitea 同机/同网:Release → Forgejo 的
|
||||
# API 调用走本地不过 frps 隧道(mac runner 走 git.51yanmei.com→frps→抖,curl 超时
|
||||
@@ -198,6 +246,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 浅克隆:走 ali frps 隧道时数据量从全历史降到一层,大幅降低 Checkout 超时率
|
||||
# (外网/在家都受益;根治外网稳定见 fetch 重试 + frps 调优)。
|
||||
fetch-depth: 1
|
||||
|
||||
# 一次性下所有 artifact(不带 name),避免同 job 内两次复用 download-artifact
|
||||
# action → act 对其只读缓存 git 仓库做二次操作时 EACCES(pack idx 444)。
|
||||
|
||||
@@ -16,6 +16,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 浅克隆:actions/checkout 从 GITHUB_SERVER_URL=http://git.51yanmei.com 克隆
|
||||
# (解析到 ali 公网→发夹弯绕回 NAS,慢且间歇 reset);全历史几十 MB 走这条链路
|
||||
# 必挂,depth=1 降到几 MB 大幅提升 Checkout 成功率。与 deploy-client 一致。
|
||||
fetch-depth: 1
|
||||
|
||||
# runner 镜像(catthehacker ubuntu:act-latest,label ubuntu-latest)自带 node
|
||||
# 但**不带 go** → 直接 `go build` 会 `go: command not found`(exit 127)。
|
||||
|
||||
@@ -16,6 +16,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 浅克隆:actions/checkout 从 GITHUB_SERVER_URL=http://git.51yanmei.com 克隆
|
||||
# (解析到 ali 公网→发夹弯绕回 NAS,慢且间歇 reset);全历史几十 MB 走这条链路
|
||||
# 必挂,depth=1 降到几 MB 大幅提升 Checkout 成功率。与 deploy-client 一致。
|
||||
fetch-depth: 1
|
||||
|
||||
# runner 镜像 catthehacker/ubuntu:act-latest 自带 node/npx,直接跑;
|
||||
# 不用嵌套 docker run(job 容器内的 $PWD 在宿主上不存在,DinD 挂载会失败)。
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '14.0'
|
||||
platform :ios, '15.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -543,7 +543,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -592,7 +592,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -663,7 +663,7 @@
|
||||
"$(PROJECT_DIR)/Frameworks",
|
||||
);
|
||||
INFOPLIST_FILE = PacketTunnel/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -695,7 +695,7 @@
|
||||
"$(PROJECT_DIR)/Frameworks",
|
||||
);
|
||||
INFOPLIST_FILE = PacketTunnel/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -727,7 +727,7 @@
|
||||
"$(PROJECT_DIR)/Frameworks",
|
||||
);
|
||||
INFOPLIST_FILE = PacketTunnel/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -51,5 +51,10 @@
|
||||
⚠️ 红线词规范:避免 VPN/翻墙 等词,使用「网络加速」口径 -->
|
||||
<key>NSVPNUsageDescription</key>
|
||||
<string>Pangolin 使用网络加速通道为您的连接提供安全保护。</string>
|
||||
<!-- 出口加密合规:仅使用标准加密(TLS/REALITY 基于标准 crypto 原语,无自研加密)
|
||||
→ 声明不含「非豁免加密」,免去 App Store Connect 每次上传的加密合规问答。
|
||||
⚠️ 若后续法务判定需正式申报,改 true 并补 ITSEncryptionExportComplianceCode。 -->
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# download_urls 目前是固定「仅保留最新一份」的稳定 URL(deploy-client.sh 每次
|
||||
# 用同名文件覆盖),不是按版本变化的路径,因此这里不需要随发版改写。
|
||||
# macos 自 client-v1.0.59 起由 build-macos 产出 pangolin-macos-x64.zip 并部署到
|
||||
# /downloads,故填稳定直链;ios 走 TestFlight(无直接下载文件),保持留空。
|
||||
# /downloads,故填稳定直链;ios 走 TestFlight(无直接下载文件),填公测公开链接,客户端「有更新」按钮直接跳 TestFlight。
|
||||
version: "1.0.48"
|
||||
build_number: 10048
|
||||
force_update: false
|
||||
@@ -19,5 +19,5 @@ download_urls:
|
||||
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
|
||||
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
|
||||
macos: "https://api.yanmeiai.com/downloads/pangolin-macos-x64.zip"
|
||||
ios: ""
|
||||
ios: "https://testflight.apple.com/join/6HFfw8Jc"
|
||||
changelog: []
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CI Runner 走域名注册 + 局域网/外网无感切换 实现方案</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:29px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:52px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:28px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
pre .c{color:#6b7385}
|
||||
pre .r{color:var(--bad)}
|
||||
pre .g{color:var(--ok)}
|
||||
pre .y{color:var(--warn)}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.you{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.tag.me{background:rgba(224,136,79,.16);color:var(--accent)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.root{border-left:3px solid var(--accent)}
|
||||
.card.warn{border-left:3px solid var(--warn)}
|
||||
.card.ok{border-left:3px solid var(--ok)}
|
||||
.card.bad{border-left:3px solid var(--bad)}
|
||||
.card h3{margin-top:0}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
.ok-c{color:var(--ok)} .bad-c{color:var(--bad)} .warn-c{color:var(--warn)}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:6px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.kbd{font-family:var(--mono);font-size:.85em;color:var(--accent)}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
|
||||
a{color:var(--accent2)}
|
||||
.diagram{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:20px;margin:18px 0;overflow-x:auto}
|
||||
ol.steps{counter-reset:s;list-style:none;padding-left:0}
|
||||
ol.steps>li{counter-increment:s;position:relative;padding:2px 0 2px 40px;margin:14px 0}
|
||||
ol.steps>li::before{content:counter(s);position:absolute;left:0;top:0;width:26px;height:26px;border-radius:50%;background:var(--panel2);border:1px solid var(--border);color:var(--accent);font-family:var(--mono);font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>CI Runner 走域名注册 + 局域网/外网无感切换</h1>
|
||||
<p class="sub">gitea Actions self-hosted runner · 注册地址统一 <code>https://git.51yanmei.com</code> · 复用 pangolin 私有分流(private-split)达成家/外无感 · 2026-09-07</p>
|
||||
|
||||
<div class="lead">
|
||||
<strong>一句话:</strong>daily 代理已从 Shadowrocket 换成 <strong>pangolin(全局 TUN)</strong>——TUN 抓所有二进制、<strong>不再像 Shadowrocket 的按 App SOCKS 那样掐断 Go 二进制</strong>,所以 <code>act_runner</code> 现在能<strong>直接连域名</strong>,<span class="tag bad">relay 淘汰</span>。runner 一律注册到域名 <code>https://git.51yanmei.com</code>;<code>git.51yanmei.com</code> 早已在 pangolin 的 <code>PANGOLIN_PRIVATE_SPLIT_DOMAINS</code> 里,靠 <code>dns-system</code>(底层物理网络 DNS)解析 —— <strong>在家</strong>局域网 DNS 覆盖成 <code>192.168.3.200</code> → 命中「LAN 直连」规则直达 NAS;<strong>在外</strong>公网 DNS 返回 ali 锚点 <code>182.92.213.171</code> → 命中「私有域名走隧道」→ 经 pangolin1 出口 → ali frp → NAS。同一个域名、同一份配置,家/外自动切换。
|
||||
</div>
|
||||
|
||||
<div class="card ok">
|
||||
<h3>✅ 已就绪(本次实测确认,无需改动)</h3>
|
||||
<ul>
|
||||
<li><b>pangolin 是当前活跃代理</b>:<code>com.pangolin.pangolin.PacketTunnel</code> 系统扩展 + <code>pangolin_vpn.app</code> 在跑;无 Shadowrocket。</li>
|
||||
<li><b>域名已可直连</b>:从 runner mac 经 pangolin TUN,<code>GET https://git.51yanmei.com/</code> 与 <code>/api/v1/version</code> 均 <span class="ok-c">HTTP 200</span>(解析 <code>182.92.213.171</code>,~1.8s,走隧道→pangolin1→ali→frp→NAS)。<strong>relay 已不再必要。</strong></li>
|
||||
<li><b>pangolin 配置已含 git</b>:pangolin1 <code>/etc/pangolin/server.env</code> 第 31 行 <code>PANGOLIN_PRIVATE_SPLIT_DOMAINS=nas,git,win,brain.51yanmei.com</code>。<span class="tag ok">零改动</span></li>
|
||||
<li><b>ali 侧外网入口已就绪</b>:<code>/etc/nginx/conf.d/git.conf</code> = <code>git.51yanmei.com</code> 443(LE 证书) → <code>proxy_pass 127.0.0.1:3000</code>(frp→NAS),<strong>无 IP 白名单</strong>(不像 brain 限 <code>103.119.13.48</code>),任意来源可达。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card bad">
|
||||
<h3>❌ 差在「局域网直达」这条腿(本方案要补的)</h3>
|
||||
<ul>
|
||||
<li><b>无家内分光 DNS</b>:全网(含在家)解析 <code>git.51yanmei.com</code> 都得 <code>182.92.213.171</code>;群晖 <b>DNS Server 套件未安装</b>(<code>/var/packages/DNSServer</code> 不存在)。baize <code>domains.yaml</code> 里 nas/win/git 的「家内覆盖 192.168.3.200 待做」正是此项。<b>可行性已判定→支持</b>(见 §6)。</li>
|
||||
<li><b>NAS 443 尚无 git 的 TLS 反代条目</b>:NAS 监听 <code>0.0.0.0:443</code>(DSM nginx),需加 <code>git.51yanmei.com</code>→<code>gitea:3000</code> 的反代 + 证书。局域网直达 <code>https://git.51yanmei.com</code>(=192.168.3.200:443)必须它成立。<b>可行性已判定→支持,证书需 DNS-01/同步</b>(见 §6)。</li>
|
||||
<li><b>runner 侧仍是旧的坏配置</b>:mac-pangolin-2(gitea_id=7)标签错(<code>nas</code>)、注册到直连公网域名(Shadowrocket 时代坏);jiu 的 mac-runner(id=3)仓库级、经 relay。均待收口成<strong>一个用户级 mac runner 走域名</strong>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>0 · 目标架构(一张图)</h2>
|
||||
<div class="diagram">
|
||||
<svg viewBox="0 0 900 430" width="100%" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,PingFang SC,sans-serif">
|
||||
<defs>
|
||||
<marker id="ar" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#5fb0c9"/></marker>
|
||||
<marker id="arg" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#5ec27a"/></marker>
|
||||
<marker id="ary" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#e0b84f"/></marker>
|
||||
</defs>
|
||||
<!-- runner mac -->
|
||||
<rect x="20" y="170" width="150" height="90" rx="10" fill="#1d2129" stroke="#e0884f"/>
|
||||
<text x="95" y="198" fill="#e6e8ee" font-size="13" font-weight="700" text-anchor="middle">runner mac</text>
|
||||
<text x="95" y="218" fill="#a8afbd" font-size="11" text-anchor="middle">act_runner</text>
|
||||
<text x="95" y="234" fill="#a8afbd" font-size="11" text-anchor="middle">pangolin TUN</text>
|
||||
<text x="95" y="250" fill="#5fb0c9" font-size="10" text-anchor="middle">→ git.51yanmei.com</text>
|
||||
|
||||
<!-- DNS decision -->
|
||||
<rect x="215" y="180" width="140" height="70" rx="10" fill="#171a22" stroke="#5fb0c9"/>
|
||||
<text x="285" y="205" fill="#e6e8ee" font-size="12" font-weight="700" text-anchor="middle">私有分流判定</text>
|
||||
<text x="285" y="222" fill="#a8afbd" font-size="10" text-anchor="middle">DNS 劫持 → dns-system</text>
|
||||
<text x="285" y="237" fill="#a8afbd" font-size="10" text-anchor="middle">(底层物理网络 DNS)</text>
|
||||
<line x1="170" y1="215" x2="212" y2="215" stroke="#5fb0c9" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
|
||||
<!-- LAN lane -->
|
||||
<text x="430" y="60" fill="#5ec27a" font-size="12" font-weight="700">在家 · 局域网</text>
|
||||
<rect x="410" y="75" width="170" height="66" rx="9" fill="#1d2129" stroke="#5ec27a"/>
|
||||
<text x="495" y="99" fill="#e6e8ee" font-size="11.5" font-weight="700" text-anchor="middle">群晖 DNS Server</text>
|
||||
<text x="495" y="116" fill="#5ec27a" font-size="10.5" text-anchor="middle">git → 192.168.3.200</text>
|
||||
<text x="495" y="131" fill="#a8afbd" font-size="10" text-anchor="middle">命中「LAN 直连」规则</text>
|
||||
<rect x="640" y="75" width="230" height="66" rx="9" fill="#171a22" stroke="#5ec27a"/>
|
||||
<text x="755" y="99" fill="#e6e8ee" font-size="11.5" font-weight="700" text-anchor="middle">NAS 192.168.3.200</text>
|
||||
<text x="755" y="116" fill="#a8afbd" font-size="10.5" text-anchor="middle">:443 反代(LE证书) → gitea:3000</text>
|
||||
<text x="755" y="131" fill="#5ec27a" font-size="10" text-anchor="middle">直达,无隧道 · ~ms</text>
|
||||
<path d="M300,180 C300,108 405,108 408,108" fill="none" stroke="#5ec27a" stroke-width="1.6" marker-end="url(#arg)"/>
|
||||
<line x1="580" y1="108" x2="638" y2="108" stroke="#5ec27a" stroke-width="1.6" marker-end="url(#arg)"/>
|
||||
|
||||
<!-- WAN lane -->
|
||||
<text x="430" y="300" fill="#e0b84f" font-size="12" font-weight="700">在外 · 公网</text>
|
||||
<rect x="410" y="312" width="150" height="62" rx="9" fill="#1d2129" stroke="#e0b84f"/>
|
||||
<text x="485" y="335" fill="#e6e8ee" font-size="11" font-weight="700" text-anchor="middle">公网 DNS</text>
|
||||
<text x="485" y="351" fill="#e0b84f" font-size="10.5" text-anchor="middle">git → 182.92.213.171</text>
|
||||
<text x="485" y="366" fill="#a8afbd" font-size="9.5" text-anchor="middle">命中「私有域名走隧道」</text>
|
||||
<rect x="600" y="306" width="120" height="74" rx="9" fill="#171a22" stroke="#e0884f"/>
|
||||
<text x="660" y="330" fill="#e6e8ee" font-size="11" font-weight="700" text-anchor="middle">pangolin1</text>
|
||||
<text x="660" y="346" fill="#a8afbd" font-size="9.5" text-anchor="middle">隧道出口</text>
|
||||
<text x="660" y="361" fill="#a8afbd" font-size="9.5" text-anchor="middle">103.119.13.48</text>
|
||||
<rect x="760" y="306" width="120" height="74" rx="9" fill="#171a22" stroke="#e0b84f"/>
|
||||
<text x="820" y="330" fill="#e6e8ee" font-size="11" font-weight="700" text-anchor="middle">ali nginx</text>
|
||||
<text x="820" y="346" fill="#a8afbd" font-size="9.5" text-anchor="middle">:443 → frp</text>
|
||||
<text x="820" y="361" fill="#5ec27a" font-size="9.5" text-anchor="middle">→ NAS:3000</text>
|
||||
<path d="M300,250 C300,343 405,343 408,343" fill="none" stroke="#e0b84f" stroke-width="1.6" marker-end="url(#ary)"/>
|
||||
<line x1="560" y1="343" x2="598" y2="343" stroke="#e0b84f" stroke-width="1.6" marker-end="url(#ary)"/>
|
||||
<line x1="720" y1="343" x2="758" y2="343" stroke="#e0b84f" stroke-width="1.6" marker-end="url(#ary)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="small">两条腿只有 <b>DNS 解析结果不同</b>驱动分叉:家内被 <code>dns-system</code> 解成私网 IP → 先命中 route 里更靠前的「LAN 直连」;在外解成公网锚点 → 落到「私有域名 → auto(隧道)」。<b>runner 侧完全不感知</b>,永远只认 <code>https://git.51yanmei.com</code>。</p>
|
||||
|
||||
<h2>1 · 为什么无感切换成立(机制,已在代码里且有测试)</h2>
|
||||
<p>核心在 <code>server/internal/httpapi/clientconfig.go</code>,<code>PANGOLIN_PRIVATE_SPLIT_DOMAINS</code> 非空时注入三处,<b>顺序即优先级</b>:</p>
|
||||
<table>
|
||||
<tr><th>#</th><th>位置</th><th>作用</th></tr>
|
||||
<tr><td>①</td><td>DNS server <code>dns-system</code>(type=local,line 370-373)</td><td>私有域名不走公共 DNS(223.5.5.5/8.8.8.8),改用<strong>底层物理网络 DNS</strong>——在家=路由器/NAS 下发的局域网 DNS(含覆盖),在外=所在网络 DNS(公网记录)。</td></tr>
|
||||
<tr><td>②</td><td>DNS rule <code>{domain: 私有域名, server: dns-system}</code>(line 383-385)</td><td>把私有域名的解析定向到 ①。排在 <code>geosite-cn</code> 之前,优先级最高。</td></tr>
|
||||
<tr><td>③</td><td>route rule <code>{domain: 私有域名, outbound: auto}</code>(line 239-242)+ 前置 LAN 直连 <code>{ip_cidr:[10/8,172.16/12,192.168/16,127/8], outbound:direct}</code>(line 230-233)</td><td>在家:DNS 解出私网 IP → <strong>LAN 直连规则先命中</strong>(更靠前)→ 直达 NAS,<b>不进隧道</b>。在外:解出公网 IP → 落到私有域名规则 → <code>auto</code>(隧道) → pangolin1 出口。<code>dns.reverse_mapping=true</code> 补回域名元数据,使按 IP 发起的连接仍能命中该规则。</td></tr>
|
||||
</table>
|
||||
<p class="small">这正是 <a href="private-dest-acl-design.html">私有服务 ACL 设计</a> / <a href="private-dest-acl-plan.html">实现计划</a> 已落地的家庭内网穿透机制(nas/win/brain 同款)。git 只是复用它,<b>无新代码</b>。</p>
|
||||
|
||||
<h2>2 · 分项实现(workstreams)</h2>
|
||||
<table>
|
||||
<tr><th>#</th><th>工作项</th><th>动谁</th><th>状态</th></tr>
|
||||
<tr><td>W1</td><td>pangolin 把 git 纳入 private-split</td><td>pangolin1 <code>server.env</code></td><td><span class="tag ok">已完成</span></td></tr>
|
||||
<tr><td>W2</td><td>ali 外网入口(git nginx 443→frp→NAS,开放)</td><td>ali</td><td><span class="tag ok">已就绪</span></td></tr>
|
||||
<tr><td>W3</td><td>家内<b>分光 DNS</b>:git/nas/win → 192.168.3.200 / .88</td><td>NAS(群晖 DNS Server)+ <s>DHCP 下发</s>→仅 runner mac 手动 DNS</td><td><span class="tag ok">已完成 2026-09-07(改法,见 §8)</span></td></tr>
|
||||
<tr><td>W4</td><td>NAS <b>443 TLS 反代</b> git → gitea:3000(+证书)</td><td>NAS(DSM 反向代理 + 导入 ali LE 证书)</td><td><span class="tag ok">已完成 2026-09-07</span></td></tr>
|
||||
<tr><td>W5</td><td>runner 收口:1 个用户级 mac runner 走<b>域名</b>;退役 id=3;删 relay</td><td>dev mac(launchd)+ gitea</td><td><span class="tag ok">已完成 2026-09-07</span></td></tr>
|
||||
<tr><td>W6</td><td>验证 + 回写 baize 台账</td><td>—</td><td><span class="tag warn">待做</span></td></tr>
|
||||
</table>
|
||||
|
||||
<div class="card">
|
||||
<p><b>降级路径(若暂不做 W3/W4):</b>runner <b>现在</b>就能改注册到域名并删 relay(W5 单独可行)——在家会走「隧道→pangolin1→ali→NAS」的<b>回环</b>路径(实测 ~1.8s/请求,能用但慢)。W3+W4 是把在家这条腿从「回环」优化成「局域网直达(~ms)」,即你要的「局域网域名直接解析成局域网地址」。<b>建议:W5 先落地拿掉 relay,W3/W4 作为随后的直连优化。</b></p>
|
||||
</div>
|
||||
|
||||
<h3>W3 · 家内分光 DNS <span class="tag you">你确认/我执行</span></h3>
|
||||
<p>目标:局域网内解析 <code>git/nas/win.51yanmei.com</code> → 私网 IP;其余照常公网。方案(二选一):</p>
|
||||
<ul>
|
||||
<li><b>群晖 DNS Server(推荐,baize 既定方向)</b>:DSM 套件中心装 <b>DNS Server</b> → 建 Master Zone <code>51yanmei.com</code>(或用「解析区域 + A 记录覆盖」)→ 加 A:<code>git 192.168.3.200</code>、<code>nas 192.168.3.200</code>、<code>win 192.168.3.88</code>;开启 forwarders 把其余查询转公网(223.5.5.5)。<b>DHCP 下发 DNS 指向 NAS</b>——好消息:<b>NAS 本身就是 LAN 的 DHCP 服务器</b>(<code>DhcpServer</code> 套件已装、引擎 dnsmasq、段 .10–.230),在 DSM DHCP 设置里把下发 DNS 改成 <code>192.168.3.200</code> 即可,<b>无需碰路由器</b>。</li>
|
||||
<li><b>ContainerManager 容器(备选,已装)</b>:跑 AdGuard Home / dnsmasq 容器做 <code>git→192.168.3.200</code> rewrite,DHCP 下发指向它。免装 DSM DNS Server 套件。</li>
|
||||
</ul>
|
||||
<p class="small"><b>注意</b>:装 DNS Server / 改 DHCP 下发 = 机器与网络改动,按规矩<strong>先经你确认</strong>再动。<code>dns-system</code>(type=local)吃的是 OS resolver → DHCP 下发的 DNS,所以 DHCP 那一步不可省(NAS 既是 DHCP 服务器,这步在 DSM 内完成)。</p>
|
||||
|
||||
<h3>W4 · NAS 443 为 git 供 TLS 反代 <span class="tag you">你确认/我执行</span></h3>
|
||||
<p>局域网直达 <code>https://git.51yanmei.com</code> 即打到 <code>192.168.3.200:443</code>,须 NAS 在 443 用 <b>git.51yanmei.com 有效证书</b>反代到 <code>gitea:3000</code>。步骤:</p>
|
||||
<ol class="steps">
|
||||
<li>先<b>证实现状</b>(需 sudo,你在 NAS 上跑或授权我):看 DSM「控制面板 → 登录门户 → 高级 → 反向代理」是否已有 <code>git.51yanmei.com</code> 条目;<code>sudo ss -tlnp | grep :443</code> 看谁占 443。</li>
|
||||
<li>若<b>无</b>:DSM 反向代理新建 <code>git.51yanmei.com:443</code> → <code>localhost:3000</code>,勾 HTTP/2、WebSocket;<code>client_max_body_size 0</code> 等价项在 DSM 里放开大包(git push)。</li>
|
||||
<li><b>证书</b>:DSM「安全性 → 证书」为 <code>git.51yanmei.com</code> 配 LE 证书。家内无 80 端口暴露→用 <b>DNS-01</b>(阿里云 DNS,AK 在 Bitwarden「ali dev」),或从 ali <code>/etc/letsencrypt/live/git.51yanmei.com/</code> 定期同步导入。绑到反代条目。</li>
|
||||
</ol>
|
||||
<p class="small">gitea 的 <code>ROOT_URL</code> 已是 <code>https://git.51yanmei.com</code>(外网就这么跑),Host 头一致,反代到 3000 无需改 gitea。</p>
|
||||
|
||||
<h3>W5 · runner 收口到域名 + 删 relay <span class="tag me">我执行(经你确认)</span></h3>
|
||||
<p>act_runner 的标签在<b>首次注册即固定</b>,改配置不重注册无效——所以必须重注册。目标态:<b>一个用户级 mac runner</b>。</p>
|
||||
<ol class="steps">
|
||||
<li><b>取用户级注册 token</b>:gitea 网页 头像 → Settings → Actions → Runners → <b>Create new Runner</b>(用户级 reg-token 的 API 端点本版本 404,只能网页取)。<span class="tag you">你提供</span></li>
|
||||
<li><b>注册</b>新 runner:<code>instance = https://git.51yanmei.com</code>(<b>域名,不是 127.0.0.1:13000,不是 IP</b>),labels <code>mac,self-hosted,macos</code>,name <code>mac</code>。</li>
|
||||
<li><b>launchd 收敛</b>:新建 <code>com.yanmei.act-runner</code>(用户级常驻,配置目录 <code>~/.act_runner_mac_config.yaml</code>);<b>卸载</b> <code>com.pangolin.act-runner</code>(mac-pangolin-2) 与 <code>com.jiu.act-runner</code>。</li>
|
||||
<li><b>删 relay</b>:<code>com.jiu.act-runner</code> 的 start 脚本里附带拉起 <code>~/bin/tcp_relay.py</code>——runner 走域名后 relay 无用,随该 launchd 一并退休(脚本、plist、13000 监听全清)。</li>
|
||||
<li><b>gitea 退役</b>旧 runner:删 mac-pangolin-2(id=7) 与 mac-runner(id=3)。jiu 仓的 job 由新用户级 mac runner 接管(标签 <code>mac</code>;如 jiu workflow 用 <code>runs-on: mac</code> 直接兼容,否则补标签)。</li>
|
||||
</ol>
|
||||
|
||||
<div class="card warn">
|
||||
<h3>⚠️ relay 到底还要不要(结论)</h3>
|
||||
<p>relay(<code>tcp_relay.py</code> 127.0.0.1:13000 → NAS 192.168.3.200:3000)当初只为绕开 <b>Shadowrocket 拦 act_runner 这个 Go 二进制的直连</b>(Python 进程不被 Shadowrocket 拦)。现在 daily 代理是 pangolin 全局 TUN,<b>TUN 不做按-App 拦截、对 Go 二进制透明</b>,实测域名直连 200 通。<strong>relay 已淘汰,随 W5 删除。</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="card ok">
|
||||
<h3>✅ W5 执行记录(2026-09-07)</h3>
|
||||
<p>比预想更简单——<b>无需重注册、无需注册 token</b>:mac-pangolin-2(id=7)<b>本就是用户级、start 脚本本就指向域名</b>,唯一 bug 是 label 错成 <code>nas</code>。根因是当年 Shadowrocket 掐断连接 → act_runner 的 <code>Declare(labels)</code> 从没上报成功。换 pangolin 后连通,做法:</p>
|
||||
<ol>
|
||||
<li>改 <code>~/.act_runner_pangolin2_config.yaml</code> 的 <code>labels: nas:host → mac:host</code>。</li>
|
||||
<li><code>launchctl kickstart -k gui/501/com.pangolin.act-runner</code> 重启 → 日志 <code>declare successfully, labels:[mac]</code>,并<b>立即接走 pangolin 队列 task 2299</b>。</li>
|
||||
<li>退役 jiu:<code>launchctl bootout</code> <code>com.jiu.act-runner</code>+watchdog(连带杀 relay)→ plist 改名 <code>.disabled</code> → repo API 删 id=3(<code>DELETE …/repos/wangjia/jiu/actions/runners/3</code> = 204)。</li>
|
||||
<li>删 relay:<code>~/bin/tcp_relay.py</code> / jiu start 脚本 / jiu config 均 <code>.retired</code>;<code>:13000</code> 已释放。</li>
|
||||
</ol>
|
||||
<p class="small">现状:单一用户级 mac runner(id=7,<code>com.pangolin.act-runner</code>,label <code>mac</code>,域名直连、无 relay),服务全部 wangjia 仓。回写 baize <code>ci_runners</code>(commit 4b7ffe1)。<b>遗留(非阻塞)</b>:label 仍单 <code>mac</code>(可后加 self-hosted/macos);launchd 名仍带 pangolin(拟后续中性化 <code>com.yanmei.act-runner</code>)。在家目前走隧道回环(~1.8s),W3/W4 后转局域网直达。</p>
|
||||
</div>
|
||||
|
||||
<h2>3 · 迁移顺序(依赖)</h2>
|
||||
<ol class="steps">
|
||||
<li><b>W5 先行</b>(可独立):runner 改注册到域名 + 删 relay。此刻在家走回环(能跑绿),先把「多头 + relay + 坏标签」这堆乱账清掉。</li>
|
||||
<li><b>W4</b>:NAS 443 反代 + 证书(先证实、再补建)。</li>
|
||||
<li><b>W3</b>:家内分光 DNS + DHCP 指向 NAS。W3 生效后,家内解析即翻私网 IP,runner/所有设备访问 git/nas/win 自动走局域网直达。</li>
|
||||
<li><b>W6</b>:验证(见下)+ 回写 baize(DNS 覆盖落地、relay 退役、runner 台账更新)。</li>
|
||||
</ol>
|
||||
<p class="small">W3 与 W4 是一对:只做 W3 不做 W4,家内会打到 192.168.3.200:443 但无有效 TLS → 失败;两者同批上。</p>
|
||||
|
||||
<h2>4 · 验证清单(W6)</h2>
|
||||
<pre><span class="c"># 在家(局域网)——应直达私网、无隧道回环</span>
|
||||
dig +short git.51yanmei.com <span class="c"># 期望 192.168.3.200</span>
|
||||
curl -sS -o /dev/null -w "%{remote_ip} %{time_total}\n" https://git.51yanmei.com/api/v1/version
|
||||
<span class="c"># 期望 ip=192.168.3.200,time≈ms 级</span>
|
||||
|
||||
<span class="c"># 在外(手机热点/换网)——应回落公网锚点、经隧道</span>
|
||||
dig +short git.51yanmei.com <span class="c"># 期望 182.92.213.171</span>
|
||||
curl ... /api/v1/version <span class="c"># 200,ip=182.92.213.171</span>
|
||||
|
||||
<span class="c"># runner 状态</span>
|
||||
launchctl list | grep act-runner <span class="c"># 只剩 com.yanmei.act-runner</span>
|
||||
lsof -i :13000 <span class="c"># 空(relay 已死)</span>
|
||||
</pre>
|
||||
<p>gitea 网页 Runners:只余 <code>windows</code>(id=8) / <code>mac</code>(新,用户级) / <code>nas</code>(id=2,实例级不动)。触发一次 pangolin <code>build-macos</code> 确认被 <code>mac</code> runner 接走、跑绿。</p>
|
||||
|
||||
<h2>6 · NAS 可行性判定(read-only 实测 2026-09-07)</h2>
|
||||
<p>「涉及 NAS 先判可行性,别做一半发现一般 nas 不支持」——判定结论:<b>这台 DS925+ 不属于「不支持」的那类</b>。它是 Plus x86 机(<code>x86_64</code> / DSM <code>7.3.2</code>),关键能力齐备。</p>
|
||||
<table>
|
||||
<tr><th>能力</th><th>现状</th><th>判定</th></tr>
|
||||
<tr><td>NAS 是否 LAN DHCP 服务器</td><td><code>DhcpServer</code> 套件<b>已装</b>,引擎正是 <b>dnsmasq</b>(<code>dnsmasq-2.x-virtual-dhcpserver</code>),租约段 <code>192.168.3.10–230</code></td><td class="ok-c">✅ DNS 下发掌握在自己手里,无需改路由器</td></tr>
|
||||
<tr><td>DNS Server(分光 A 记录)</td><td>套件未装,但 DS925+/DSM7.3 catalog 支持;另 <code>ContainerManager</code> <b>已装</b>可跑 AdGuard/dnsmasq 容器</td><td class="ok-c">✅ 两条路径(官方套件 / 容器)任选</td></tr>
|
||||
<tr><td>443 TLS 反代</td><td>443 由 DSM nginx 持有;<b>DSM Reverse Proxy 已在用</b>(<code>/usr/syno/etc/www/ReverseProxy.json</code> 有条目);gitea 为 3000 上 native 进程(非容器)</td><td class="ok-c">✅ 加一条 SNI vhost git→localhost:3000 即可</td></tr>
|
||||
<tr><td>证书签发</td><td>DSM 内置 LE 仅 HTTP-01,家里 git 的 :80 不对公网→<b>一键签不了</b>;acme.sh 未装</td><td class="warn-c">⚠ 唯一非默认项:走 <b>acme.sh DNS-01</b>(阿里云 DNS,AK 在 Bitwarden「ali dev」) 或<b>同步 ali 已有 LE 证书</b>(cron scp+DSM 导入,最省)</td></tr>
|
||||
</table>
|
||||
<div class="card ok">
|
||||
<p><b>判定:W3 + W4 均可行。</b>唯一 caveat 是证书需 DNS-01 或同步(非 DSM 一键 LE),但两条路都成熟、不阻塞。DHCP 已由 NAS(dnsmasq) 承担、ContainerManager 与 Reverse Proxy 都在——不存在「一般 nas 不支持」的情形。<br><span class="small">未证实项(需 sudo 才能读,不阻塞判定):ReverseProxy.json 具体条目、certificate 目录内容、DHCP 当前下发的 DNS 指向谁。落地时一并处理。</span></p>
|
||||
</div>
|
||||
|
||||
<h2>7 · 需要你拍板 / 提供的(机器改动,先确认再动)</h2>
|
||||
<table>
|
||||
<tr><th>项</th><th>属于</th><th>需要你</th></tr>
|
||||
<tr><td>用户级 runner 注册 token(网页 Create new Runner 取)</td><td>W5</td><td>提供 token</td></tr>
|
||||
<tr><td>装群晖 DNS Server + 改路由器 DHCP DNS</td><td>W3</td><td>确认动手(装套件+改网络)</td></tr>
|
||||
<tr><td>NAS DSM 反向代理 + 证书(DNS-01 或同步 ali 证书)</td><td>W4</td><td>确认动手 / 或授权 sudo 让我先证实现状</td></tr>
|
||||
<tr><td>卸载 <code>com.jiu.act-runner</code> / <code>com.pangolin.act-runner</code>、删 relay</td><td>W5</td><td>确认(会短暂影响 jiu CI,直到新 runner 接管)</td></tr>
|
||||
</table>
|
||||
<p class="small">相关台账:<code>~/code/baize/data/hosts.yaml</code> <code>ci_runners</code> 段(relay_note、mac runner state)+ <code>domains.yaml</code>(nas/win/git「家内覆盖 待做」)。W3/W5 落地后回写。</p>
|
||||
|
||||
<h2>8 · W3/W4 执行记录(2026-09-07,实际落地与偏差)</h2>
|
||||
<div class="card ok">
|
||||
<h3>✅ 结果:git 在家从隧道回环 ~1.8s → <b>局域网直达 24ms</b></h3>
|
||||
<p><code>curl https://git.51yanmei.com/api/v1/version</code> 从 runner mac 经 pangolin:<code>http=200 ip=192.168.3.200 time=0.025s</code>,证书受信。runner 重启后 <code>declare successfully</code>、无 <code>no such host</code>。</p>
|
||||
</div>
|
||||
<h3>W4(NAS 443 反代)— 按计划</h3>
|
||||
<ul>
|
||||
<li>DSM 反向代理原有 <code>git.51yanmei.com</code> 条目从 <b>HTTP:80 改 HTTPS:443</b>,目的地仍 <code>localhost:3000</code>。</li>
|
||||
<li>证书:把 ali 上 <code>/etc/letsencrypt/live/git.51yanmei.com/</code> 的 LE 证书(cert/privkey/chain)导入 DSM,绑到该反代服务。(我这边拉私钥被安全闸拦,改由用户在自己终端拉取 + DSM GUI 导入。)</li>
|
||||
</ul>
|
||||
<h3>W3(家内分光 DNS)— <span class="tag warn">改了方案</span></h3>
|
||||
<p>原计划「NAS 接管 DHCP、下发 .200 当 DNS」<b>失败</b>——根因:家里网关是 <b>华为 Q2S 路由器,带防私接 DHCP</b>,把 NAS(dnsmasq,绑 :67 正常)发出的 DHCP 应答全丢,客户端永远拿不到 NAS 租约(华为 DHCP 关掉后 mac 也 DISCOVER 不到 NAS→一度 No-IP,靠重开华为 DHCP 兜底恢复)。NAS 不是路由器、改不了这个。</p>
|
||||
<p>改成的可行方案:</p>
|
||||
<ol class="steps">
|
||||
<li>NAS 装 <b>Synology DNS Server</b>(<code>synopkg install_from_server DNSServer</code>):给 <code>git/nas/win.51yanmei.com</code> 各建<b>单主机主区</b>(只对这三名字权威→私网 IP)+ <code>named.options.user.conf</code> 加 <code>forwarders{223.5.5.5;119.29.29.29}</code> 转发其余。故 <code>pay/jiu/api</code> 等兄弟子域照常公网。</li>
|
||||
<li><b>只给 runner mac 的家里 Wi-Fi 手动指 DNS=<code>192.168.3.200</code></b>(macOS 按网络记,出门用别的网 DNS、不受影响)。别的家里设备不动(它们不需要 git 覆盖)。NAS 的 DhcpServer 已停,华为继续当 DHCP。</li>
|
||||
<li>⚠ 改 DNS 后 mDNSResponder 负缓存旧失败(<code>dig</code> 通但 <code>curl</code> no-such-host)→ <code>sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder</code>;runner 再 <code>launchctl kickstart -k</code> 重启。</li>
|
||||
</ol>
|
||||
<p class="small"><b>已知/待办</b>:① 只有 runner mac 走直达(本就够用,它才是 CI 需要的),非全网分光。② DNS=.200 仅设在家里 Wi-Fi;若 mac 换网上不了网就删这条。③ NAS DhcpServer 已 <code>stop</code>,如需永久别开机自启可在 DSM「DHCP Server」取消勾选。④ 台账真相源:baize <code>domains.yaml</code>『家内分光』note;NAS 详细運維归 <code>~/code/nas</code>。</p>
|
||||
|
||||
<hr>
|
||||
<p class="small">关联:<a href="private-dest-acl-design.html">私有服务 ACL 设计</a> · <a href="private-dest-acl-plan.html">私有服务 ACL 实现计划</a> · <a href="control-plane-tls-tunnel.html">控制面 TLS(Tunnel 前置)</a> · <a href="index.html">← 文档索引</a></p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -146,6 +146,11 @@
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-05-cicd.md</code>(含 checkbox)。三期 11 任务:Phase1 基座+官网+服务端(无签名可立即上线,服务端固化 F3/F4 备份/迁移/回滚) → Phase2 Android(接 release keystore 签名,解锁下载链接) → Phase3 macOS 公证 dmg + Windows 安装包。runner 混合 nas/mac/windows;密钥已建(对齐 jiu)。设计见 cicd-design.html。</div>
|
||||
<div class="path">docs/cicd-plan.html · 真相源 docs/superpowers/plans/2026-07-05-cicd.md</div>
|
||||
</a>
|
||||
<a class="doc" href="ci-runner-domain-registration-plan.html">
|
||||
<div class="t">CI Runner 走域名注册 + 局域网/外网无感切换 <span class="tag html">HTML</span></div>
|
||||
<div class="d">daily 代理已从 Shadowrocket 换成 pangolin 全局 TUN(对 Go 二进制透明,不再掐 act_runner 直连)→ <b>relay 淘汰</b>,runner 一律注册到域名 <code>https://git.51yanmei.com</code>。git 早在 <code>PANGOLIN_PRIVATE_SPLIT_DOMAINS</code>,靠 <code>dns-system</code> 解析:在家局域网 DNS 覆盖成 192.168.3.200 → LAN 直连规则先命中直达 NAS;在外公网锚点 182.92.213.171 → 私有域名走隧道 → pangolin1 出口 → ali frp → NAS。已就绪(pangolin配置/ali入口/域名实测200);待补 W3 家内分光 DNS(群晖 DNS Server+DHCP)+W4 NAS 443 TLS 反代+W5 runner 收口成一个用户级 mac runner 并删 relay。机制复用私有服务 ACL。</div>
|
||||
<div class="path">docs/ci-runner-domain-registration-plan.html</div>
|
||||
</a>
|
||||
<a class="doc" href="device-session-management-plan.html">
|
||||
<div class="t">设备 & 会话管理 + 每设备流量归因 实现计划(P1–P6)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源为 <code>docs/superpowers/plans/2026-06-29-device-session-management.md</code>(含 checkbox)。P1 设备注册打通 → P2 sessions表+在线/最后登录 → P3 强制退出/清除 → P4 每设备流量 → P5 2FA信任(future) → P6 UI重做。</div>
|
||||
|
||||
@@ -57,7 +57,14 @@ rm -rf "$WORK/sing-box"
|
||||
# sing-box 源码:默认 github,CI 里国内 runner 连不上 github → 经 SINGBOX_GIT 指向
|
||||
# NAS gitea 镜像(wangjia/sing-box,gitea migrate 自 github)。见 deploy-client.yml。
|
||||
SINGBOX_GIT="${SINGBOX_GIT:-https://github.com/SagerNet/sing-box.git}"
|
||||
git clone --depth 1 --branch "$SINGBOX_VERSION" "$SINGBOX_GIT" "$WORK/sing-box"
|
||||
# 已是 --depth 1 浅克隆;外网 runner 经 ali frps 隧道 clone 偶发超时 → 重试 3 次。
|
||||
_sb_ok=
|
||||
for _try in 1 2 3; do
|
||||
rm -rf "$WORK/sing-box"
|
||||
if git clone --depth 1 --branch "$SINGBOX_VERSION" "$SINGBOX_GIT" "$WORK/sing-box"; then _sb_ok=1; break; fi
|
||||
echo " sing-box clone 第 $_try 次失败(frps 抖动?),12s 后重试..."; sleep 12
|
||||
done
|
||||
[ -n "$_sb_ok" ] || { echo "✗ sing-box clone 重试耗尽($SINGBOX_GIT)"; exit 1; }
|
||||
cd "$WORK/sing-box"
|
||||
|
||||
echo "==> [3/3] 用官方 build_libbox 编核心库"
|
||||
|
||||
@@ -375,6 +375,117 @@ func (h *Handlers) AuditPage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Users(使用状态观测,只读)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const usersPageLimit = 50
|
||||
|
||||
type userRowView struct {
|
||||
UserRow
|
||||
DaysLeft int // 订阅剩余天数(向上取整);SubExpires 为空时无意义
|
||||
Tags []string // 观测标签:已过期 / 将到期 / 流失 / 试用中
|
||||
}
|
||||
|
||||
type usersView struct {
|
||||
Stats UserStats
|
||||
Rows []userRowView
|
||||
Query string
|
||||
ActiveDays int
|
||||
Paid string
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevURL string
|
||||
NextURL string
|
||||
}
|
||||
|
||||
// UsersPage renders the user usage overview (GET /users).
|
||||
func (h *Handlers) UsersPage(w http.ResponseWriter, r *http.Request) {
|
||||
sess := SessionFromContext(r.Context())
|
||||
q := r.URL.Query()
|
||||
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
// 默认「最近 30 天活跃」;active=0 表示全部。
|
||||
activeDays := 30
|
||||
if v := q.Get("active"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
activeDays = n
|
||||
}
|
||||
}
|
||||
paid := q.Get("paid")
|
||||
if paid != "paid" && paid != "free" {
|
||||
paid = ""
|
||||
}
|
||||
search := strings.TrimSpace(q.Get("q"))
|
||||
|
||||
f := UsersFilter{
|
||||
Query: search, ActiveDays: activeDays, Paid: paid,
|
||||
Limit: usersPageLimit, Offset: offset,
|
||||
}
|
||||
rows, total, err := h.store.ListUsers(r.Context(), f)
|
||||
if err != nil {
|
||||
h.serverError(w, "list users", err)
|
||||
return
|
||||
}
|
||||
stats, err := h.store.UserSummary(r.Context())
|
||||
if err != nil {
|
||||
h.serverError(w, "user summary", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
views := make([]userRowView, 0, len(rows))
|
||||
for _, u := range rows {
|
||||
v := userRowView{UserRow: u}
|
||||
if u.SubExpires != nil {
|
||||
d := int(u.SubExpires.Sub(now).Hours() / 24)
|
||||
if u.SubExpires.After(now) {
|
||||
d++ // 向上取整:未过期至少剩 1 天
|
||||
}
|
||||
v.DaysLeft = d
|
||||
switch {
|
||||
case d <= 0:
|
||||
v.Tags = append(v.Tags, "已过期")
|
||||
case d <= 3:
|
||||
v.Tags = append(v.Tags, fmt.Sprintf("将到期%dd", d))
|
||||
}
|
||||
}
|
||||
if u.LastActive != nil && now.Sub(*u.LastActive) > 7*24*time.Hour {
|
||||
v.Tags = append(v.Tags, fmt.Sprintf("流失%dd", int(now.Sub(*u.LastActive).Hours()/24)))
|
||||
}
|
||||
if !u.HasPaid && u.SubSource == "trial" {
|
||||
v.Tags = append(v.Tags, "试用中")
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
view := usersView{
|
||||
Stats: stats, Rows: views, Query: search, ActiveDays: activeDays, Paid: paid,
|
||||
Total: total,
|
||||
HasPrev: offset > 0, HasNext: offset+usersPageLimit < total,
|
||||
PrevURL: usersURL(q, maxInt(0, offset-usersPageLimit)),
|
||||
NextURL: usersURL(q, offset+usersPageLimit),
|
||||
}
|
||||
h.render.render(w, "users", pageData{
|
||||
Username: sess.Username, CSRF: sess.CSRFToken, Active: "users", Data: view,
|
||||
})
|
||||
}
|
||||
|
||||
func usersURL(q url.Values, offset int) string {
|
||||
nq := url.Values{}
|
||||
for _, k := range []string{"q", "active", "paid"} {
|
||||
if v := q.Get(k); v != "" {
|
||||
nq.Set(k, v)
|
||||
}
|
||||
}
|
||||
nq.Set("offset", strconv.Itoa(offset))
|
||||
return "/users?" + nq.Encode()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -350,3 +350,37 @@ func TestAuditPage_Filters(t *testing.T) {
|
||||
t.Error("filter leaked non-matching entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPage_RendersSummaryAndRows(t *testing.T) {
|
||||
e := newEnv(t, true, true)
|
||||
cookie, _ := e.login(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
churned := now.Add(-10 * 24 * time.Hour)
|
||||
expSoon := now.Add(2 * 24 * time.Hour)
|
||||
paidAt := now.Add(-3 * 24 * time.Hour)
|
||||
e.store.userStats = UserStats{TotalUsers: 42, Active7d: 9, PaidUsers: 4, New7d: 5, Week7dBytes: 5 << 30}
|
||||
e.store.users = []UserRow{
|
||||
// 全空指针的极简行:走 "从未"/"无"/"—" 分支。
|
||||
{ID: 1, Email: "fresh@x.com", Status: "active", Registered: now},
|
||||
// 满字段行:触发 handler 的「流失」「将到期」标签 + 各展示函数。
|
||||
{
|
||||
ID: 2, Email: "vip@x.com", Status: "active", TOTPEnabled: true, Registered: now.Add(-60 * 24 * time.Hour),
|
||||
LastActive: &churned, Plan: "pro", SubSource: "pay", SubExpires: &expSoon,
|
||||
HasPaid: true, PayTotalMinor: 12800, PayCurrency: "CNY", LastPaidAt: &paidAt,
|
||||
DeviceCount: 3, LastPlatform: "ios", LastDeviceName: "iPhone", ClientVersion: "1.2.6",
|
||||
WeekBytesUp: 1 << 20, WeekBytesDown: 3 << 20, WeekMinutes: 145, WeekActiveDays: 5, InviteCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
rec := e.do(t, "GET", "/users", nil, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d; want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"用户使用状态", "客户端版本", "v1.2.6", "fresh@x.com", "vip@x.com", "付费", "流失", "将到期", "从未"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func NewRouter(h *Handlers, sessions *SessionStore, cfg *Config, sec *SecurityLo
|
||||
pr.Post("/codes/void", h.VoidBatch)
|
||||
pr.Get("/nodes", h.NodesPage)
|
||||
pr.Post("/nodes/op", h.NodeOp)
|
||||
pr.Get("/users", h.UsersPage)
|
||||
pr.Get("/audit", h.AuditPage)
|
||||
})
|
||||
return r
|
||||
|
||||
@@ -43,3 +43,15 @@ th { background:#fafbfd; color:var(--muted); font-weight:600; }
|
||||
.status-draining { background:#fdf6e7; color:var(--warn); }
|
||||
.status-down, .status-destroyed { background:#fbeeec; color:var(--danger); }
|
||||
.ev { font-size:12px; }
|
||||
.stats { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:20px; }
|
||||
.stat { background:#fff; border:1px solid var(--line); border-radius:8px; padding:12px 16px; min-width:130px; }
|
||||
.stat b { display:block; font-size:22px; line-height:1.2; }
|
||||
.stat span { color:var(--muted); font-size:12px; }
|
||||
.tablewrap { overflow-x:auto; }
|
||||
.tag { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; background:#fdf6e7; color:var(--warn); margin:0 2px 2px 0; }
|
||||
.tag.danger { background:#fbeeec; color:var(--danger); }
|
||||
.pill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; background:#eef0f5; color:var(--fg); }
|
||||
.pill.pay { background:#e6f0ff; color:var(--accent); }
|
||||
.pill.free { background:#eef0f5; color:var(--muted); }
|
||||
.usage { font-size:12px; white-space:nowrap; }
|
||||
.small { font-size:11px; color:var(--muted); }
|
||||
|
||||
@@ -29,6 +29,10 @@ type Store interface {
|
||||
WriteAudit(ctx context.Context, actor, action, target, metaJSON string) error
|
||||
QueryAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, int, error)
|
||||
QueryNodeEvents(ctx context.Context, nodeID int64, limit int) ([]NodeEvent, error)
|
||||
|
||||
// User usage overview (内部观测,只读).
|
||||
ListUsers(ctx context.Context, f UsersFilter) ([]UserRow, int, error)
|
||||
UserSummary(ctx context.Context) (UserStats, error)
|
||||
}
|
||||
|
||||
// DBStore implements Store over MySQL.
|
||||
@@ -241,3 +245,382 @@ func (s *DBStore) QueryNodeEvents(ctx context.Context, nodeID int64, limit int)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User usage overview(内部观测页;全程可移植 SQL,时间边界 Go 端算好传 ?)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const usersListLimit = 50
|
||||
|
||||
// ListUsers returns a filtered, paginated slice of users with their aggregated
|
||||
// usage picture, plus the total match count. It runs one lean, paginated query
|
||||
// over users (with scalar subqueries for last-activity / first-seen / counts),
|
||||
// then batch-loads the per-page related aggregates (subscription, weekly usage,
|
||||
// latest device, latest session, pay totals) keyed by user id.
|
||||
func (s *DBStore) ListUsers(ctx context.Context, f UsersFilter) ([]UserRow, int, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = usersListLimit
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
// WHERE over the derived projection (alias-referencing is portable here).
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if q := strings.TrimSpace(f.Query); q != "" {
|
||||
where = append(where, "email LIKE ?")
|
||||
args = append(args, "%"+q+"%")
|
||||
}
|
||||
if f.ActiveDays > 0 {
|
||||
where = append(where, "last_active >= ?")
|
||||
args = append(args, now.AddDate(0, 0, -f.ActiveDays))
|
||||
}
|
||||
switch f.Paid {
|
||||
case "paid":
|
||||
where = append(where, "first_paid_at IS NOT NULL")
|
||||
case "free":
|
||||
where = append(where, "first_paid_at IS NULL")
|
||||
}
|
||||
clause := strings.Join(where, " AND ")
|
||||
|
||||
// The inner projection: one row per user with the fields needed to filter,
|
||||
// order and display without extra round-trips.
|
||||
base := `SELECT id, email, status, totp_enabled, registered, first_paid_at,
|
||||
last_active, first_seen, device_count, invite_count
|
||||
FROM (
|
||||
SELECT u.id AS id, u.email AS email, u.status AS status,
|
||||
u.totp_enabled AS totp_enabled, u.created_at AS registered,
|
||||
u.first_paid_at AS first_paid_at,
|
||||
(SELECT MAX(la) FROM (
|
||||
SELECT MAX(s.last_active) AS la FROM sessions s WHERE s.user_id = u.id
|
||||
UNION ALL
|
||||
SELECT MAX(d.last_seen) AS la FROM devices d WHERE d.user_id = u.id
|
||||
) act) AS last_active,
|
||||
(SELECT MIN(fc) FROM (
|
||||
SELECT MIN(s.created_at) AS fc FROM sessions s WHERE s.user_id = u.id
|
||||
UNION ALL
|
||||
SELECT MIN(d.created_at) AS fc FROM devices d WHERE d.user_id = u.id
|
||||
) fst) AS first_seen,
|
||||
(SELECT COUNT(*) FROM devices d WHERE d.user_id = u.id) AS device_count,
|
||||
(SELECT COUNT(*) FROM referrals r WHERE r.inviter_id = u.id) AS invite_count
|
||||
FROM users u
|
||||
) uu
|
||||
WHERE ` + clause
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM (`+base+`) c`, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("admin.ListUsers count: %w", err)
|
||||
}
|
||||
|
||||
q := base + ` ORDER BY (last_active IS NULL), last_active DESC, registered DESC LIMIT ? OFFSET ?`
|
||||
qArgs := append(append([]any{}, args...), limit, f.Offset)
|
||||
rows, err := s.db.QueryContext(ctx, q, qArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("admin.ListUsers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []UserRow
|
||||
byID := map[int64]*UserRow{}
|
||||
for rows.Next() {
|
||||
var u UserRow
|
||||
var totp int64
|
||||
var firstPaid sql.NullTime
|
||||
// last_active / first_seen come from MAX()/MIN() subqueries; the SQLite
|
||||
// driver returns those derived time columns as strings (MySQL gives
|
||||
// time.Time), so scan into any and normalise via asTimePtr.
|
||||
var lastActive, firstSeen any
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Status, &totp, &u.Registered, &firstPaid,
|
||||
&lastActive, &firstSeen, &u.DeviceCount, &u.InviteCount); err != nil {
|
||||
return nil, 0, fmt.Errorf("admin.ListUsers scan: %w", err)
|
||||
}
|
||||
u.TOTPEnabled = totp != 0
|
||||
u.HasPaid = firstPaid.Valid
|
||||
u.FirstPaidAt = nullTimePtr(firstPaid)
|
||||
u.LastActive = asTimePtr(lastActive)
|
||||
u.FirstSeen = asTimePtr(firstSeen)
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range out {
|
||||
byID[out[i].ID] = &out[i]
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
ids := make([]int64, len(out))
|
||||
for i := range out {
|
||||
ids[i] = out[i].ID
|
||||
}
|
||||
if err := s.fillSubscriptions(ctx, byID, ids); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.fillWeeklyUsage(ctx, byID, ids, now.AddDate(0, 0, -7).Format("2006-01-02")); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.fillLatestDevice(ctx, byID, ids); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.fillLatestSession(ctx, byID, ids); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.fillPayTotals(ctx, byID, ids); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// fillSubscriptions sets Plan/SubSource/SubExpires from the current effective
|
||||
// (max expires_at) subscription of each user.
|
||||
func (s *DBStore) fillSubscriptions(ctx context.Context, byID map[int64]*UserRow, ids []int64) error {
|
||||
ph, args := inPlaceholders(ids)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT s.user_id, p.code, s.expires_at, s.source
|
||||
FROM subscriptions s JOIN plans p ON p.id = s.plan_id
|
||||
WHERE s.user_id IN (`+ph+`)
|
||||
ORDER BY s.expires_at ASC`, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin.ListUsers subs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var uid int64
|
||||
var code, source string
|
||||
var exp time.Time
|
||||
if err := rows.Scan(&uid, &code, &exp, &source); err != nil {
|
||||
return fmt.Errorf("admin.ListUsers subs scan: %w", err)
|
||||
}
|
||||
// Ordered ascending by expires_at → last write per user wins = latest.
|
||||
if u := byID[uid]; u != nil {
|
||||
u.Plan, u.SubSource = code, source
|
||||
e := exp
|
||||
u.SubExpires = &e
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// fillWeeklyUsage sums the last-7-day usage_daily aggregates per user.
|
||||
func (s *DBStore) fillWeeklyUsage(ctx context.Context, byID map[int64]*UserRow, ids []int64, since string) error {
|
||||
ph, args := inPlaceholders(ids)
|
||||
args = append(args, since)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT user_id,
|
||||
COALESCE(SUM(bytes_up),0), COALESCE(SUM(bytes_down),0),
|
||||
COALESCE(SUM(minutes_used),0), COUNT(DISTINCT date),
|
||||
COALESCE(SUM(ad_bonus_minutes),0)
|
||||
FROM usage_daily
|
||||
WHERE user_id IN (`+ph+`) AND date >= ?
|
||||
GROUP BY user_id`, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin.ListUsers weekly: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var uid int64
|
||||
var up, down int64
|
||||
var mins, days, ad int
|
||||
if err := rows.Scan(&uid, &up, &down, &mins, &days, &ad); err != nil {
|
||||
return fmt.Errorf("admin.ListUsers weekly scan: %w", err)
|
||||
}
|
||||
if u := byID[uid]; u != nil {
|
||||
u.WeekBytesUp, u.WeekBytesDown = up, down
|
||||
u.WeekMinutes, u.WeekActiveDays, u.WeekAdBonusMin = mins, days, ad
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// fillLatestDevice sets the most-recently-seen device's platform/name/version.
|
||||
func (s *DBStore) fillLatestDevice(ctx context.Context, byID map[int64]*UserRow, ids []int64) error {
|
||||
ph, args := inPlaceholders(ids)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT user_id, platform, name, COALESCE(client_version,''), last_seen
|
||||
FROM devices
|
||||
WHERE user_id IN (`+ph+`)
|
||||
ORDER BY (last_seen IS NULL), last_seen DESC`, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin.ListUsers device: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var uid int64
|
||||
var platform, name, ver string
|
||||
var seen sql.NullTime
|
||||
if err := rows.Scan(&uid, &platform, &name, &ver, &seen); err != nil {
|
||||
return fmt.Errorf("admin.ListUsers device scan: %w", err)
|
||||
}
|
||||
// Rows ordered newest-first → keep only the first seen per user.
|
||||
if u := byID[uid]; u != nil && u.LastPlatform == "" {
|
||||
u.LastPlatform, u.LastDeviceName = platform, name
|
||||
if ver != "" {
|
||||
u.ClientVersion = ver
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// fillLatestSession sets the most-recent session's IP (and client_version as a
|
||||
// fallback when the device row had none).
|
||||
func (s *DBStore) fillLatestSession(ctx context.Context, byID map[int64]*UserRow, ids []int64) error {
|
||||
ph, args := inPlaceholders(ids)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT user_id, COALESCE(client_ip,''), COALESCE(client_version,''), last_active
|
||||
FROM sessions
|
||||
WHERE user_id IN (`+ph+`)
|
||||
ORDER BY (last_active IS NULL), last_active DESC`, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin.ListUsers session: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[int64]bool{}
|
||||
for rows.Next() {
|
||||
var uid int64
|
||||
var ip, ver string
|
||||
var la sql.NullTime
|
||||
if err := rows.Scan(&uid, &ip, &ver, &la); err != nil {
|
||||
return fmt.Errorf("admin.ListUsers session scan: %w", err)
|
||||
}
|
||||
if seen[uid] {
|
||||
continue
|
||||
}
|
||||
seen[uid] = true
|
||||
if u := byID[uid]; u != nil {
|
||||
u.LastIP = ip
|
||||
if u.ClientVersion == "" && ver != "" {
|
||||
u.ClientVersion = ver
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// fillPayTotals sets cumulative paid amount + last paid time per user.
|
||||
func (s *DBStore) fillPayTotals(ctx context.Context, byID map[int64]*UserRow, ids []int64) error {
|
||||
ph, args := inPlaceholders(ids)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT user_id, COALESCE(SUM(amount_minor),0), MAX(paid_at), COALESCE(MAX(currency),'')
|
||||
FROM pay_purchases
|
||||
WHERE user_id IN (`+ph+`) AND status = 'paid'
|
||||
GROUP BY user_id`, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin.ListUsers pay: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var uid int64
|
||||
var total int64
|
||||
var cur string
|
||||
var paid any // MAX(paid_at) → derived time; SQLite returns a string.
|
||||
if err := rows.Scan(&uid, &total, &paid, &cur); err != nil {
|
||||
return fmt.Errorf("admin.ListUsers pay scan: %w", err)
|
||||
}
|
||||
if u := byID[uid]; u != nil {
|
||||
u.PayTotalMinor, u.PayCurrency = total, cur
|
||||
u.LastPaidAt = asTimePtr(paid)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// UserSummary computes the top-of-page summary cards.
|
||||
func (s *DBStore) UserSummary(ctx context.Context) (UserStats, error) {
|
||||
var st UserStats
|
||||
now := time.Now().UTC()
|
||||
weekAgoDate := now.AddDate(0, 0, -7).Format("2006-01-02")
|
||||
weekAgo := now.AddDate(0, 0, -7)
|
||||
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&st.TotalUsers); err != nil {
|
||||
return st, fmt.Errorf("admin.UserSummary total: %w", err)
|
||||
}
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(DISTINCT user_id) FROM usage_daily
|
||||
WHERE date >= ? AND (bytes_up > 0 OR bytes_down > 0 OR minutes_used > 0)`,
|
||||
weekAgoDate).Scan(&st.Active7d); err != nil {
|
||||
return st, fmt.Errorf("admin.UserSummary active: %w", err)
|
||||
}
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE first_paid_at IS NOT NULL`).Scan(&st.PaidUsers); err != nil {
|
||||
return st, fmt.Errorf("admin.UserSummary paid: %w", err)
|
||||
}
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE created_at >= ?`, weekAgo).Scan(&st.New7d); err != nil {
|
||||
return st, fmt.Errorf("admin.UserSummary new: %w", err)
|
||||
}
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(bytes_up + bytes_down),0) FROM usage_daily WHERE date >= ?`,
|
||||
weekAgoDate).Scan(&st.Week7dBytes); err != nil {
|
||||
return st, fmt.Errorf("admin.UserSummary traffic: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// inPlaceholders builds "?,?,?" and the matching []any args for an IN clause.
|
||||
func inPlaceholders(ids []int64) (string, []any) {
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
return strings.Join(ph, ","), args
|
||||
}
|
||||
|
||||
// nullTimePtr converts a sql.NullTime to *time.Time (nil when NULL).
|
||||
func nullTimePtr(nt sql.NullTime) *time.Time {
|
||||
if !nt.Valid {
|
||||
return nil
|
||||
}
|
||||
t := nt.Time
|
||||
return &t
|
||||
}
|
||||
|
||||
// sqliteTimeLayouts are the text forms modernc.org/sqlite may return a
|
||||
// derived (subquery/aggregate) datetime as.
|
||||
var sqliteTimeLayouts = []string{
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST", // Go time.Time.String() — modernc's derived-column form
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05.999999999Z07:00",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
|
||||
// asTimePtr normalises a scanned value into *time.Time. Direct columns come
|
||||
// back as time.Time (MySQL, and SQLite direct columns); derived columns come
|
||||
// back as string/[]byte under SQLite. NULL / unparseable → nil.
|
||||
func asTimePtr(v any) *time.Time {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case time.Time:
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
case []byte:
|
||||
return parseTimeText(string(t))
|
||||
case string:
|
||||
return parseTimeText(t)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseTimeText(s string) *time.Time {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range sqliteTimeLayouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -33,6 +33,89 @@ var tmplFuncs = template.FuncMap{
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04:05Z")
|
||||
},
|
||||
// fmtTimeP renders a *time.Time (nil / zero → "-").
|
||||
"fmtTimeP": func(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04Z")
|
||||
},
|
||||
// relTime renders a coarse "x 前" relative to now (nil → "从未").
|
||||
"relTime": func(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "从未"
|
||||
}
|
||||
return relTimeString(*t)
|
||||
},
|
||||
// relTimeV is the value-typed variant (e.g. for non-nullable created_at).
|
||||
"relTimeV": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return relTimeString(t)
|
||||
},
|
||||
// humanBytes renders a byte count as B/KB/MB/GB/TB.
|
||||
"humanBytes": func(n int64) string {
|
||||
const u = 1024
|
||||
if n < u {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(u), 0
|
||||
for x := n / u; x >= u; x /= u {
|
||||
div *= u
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
},
|
||||
// minsHuman renders minutes as "X 分" / "X.X 小时".
|
||||
"minsHuman": func(m int) string {
|
||||
if m < 60 {
|
||||
return fmt.Sprintf("%d 分", m)
|
||||
}
|
||||
return fmt.Sprintf("%.1f 小时", float64(m)/60)
|
||||
},
|
||||
// money renders a minor-unit amount with its currency (CNY minor = 分).
|
||||
"money": func(minor int64, cur string) string {
|
||||
if minor == 0 {
|
||||
return "—"
|
||||
}
|
||||
if cur == "" {
|
||||
cur = "CNY"
|
||||
}
|
||||
return fmt.Sprintf("%.2f %s", float64(minor)/100, cur)
|
||||
},
|
||||
// subSourceZH maps a subscription source to a Chinese label.
|
||||
"subSourceZH": func(s string) string {
|
||||
switch s {
|
||||
case "trial":
|
||||
return "试用"
|
||||
case "code":
|
||||
return "兑换码"
|
||||
case "pay":
|
||||
return "付费"
|
||||
case "invite":
|
||||
return "邀请"
|
||||
case "task":
|
||||
return "任务"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// relTimeString renders a coarse "x 前" for a non-zero time.
|
||||
func relTimeString(t time.Time) string {
|
||||
d := time.Since(t.UTC())
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "刚刚"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d 分钟前", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%d 小时前", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%d 天前", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
// newRenderer parses base.html with each page template into its own set.
|
||||
@@ -42,6 +125,7 @@ func newRenderer() (*renderer, error) {
|
||||
"dashboard": {"templates/base.html", "templates/dashboard.html"},
|
||||
"codes": {"templates/base.html", "templates/codes.html"},
|
||||
"nodes": {"templates/base.html", "templates/nodes.html"},
|
||||
"users": {"templates/base.html", "templates/users.html"},
|
||||
"audit": {"templates/base.html", "templates/audit.html"},
|
||||
}
|
||||
r := &renderer{pages: make(map[string]*template.Template, len(pages))}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<a href="/" class="{{if eq .Active "dashboard"}}on{{end}}">概览</a>
|
||||
<a href="/codes" class="{{if eq .Active "codes"}}on{{end}}">码批次</a>
|
||||
<a href="/nodes" class="{{if eq .Active "nodes"}}on{{end}}">节点</a>
|
||||
<a href="/users" class="{{if eq .Active "users"}}on{{end}}">用户</a>
|
||||
<a href="/audit" class="{{if eq .Active "audit"}}on{{end}}">审计</a>
|
||||
</nav>
|
||||
<span class="who">
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<ul class="cards">
|
||||
<li><a href="/codes"><strong>码批次</strong><span>生成 / 导出 / 作废</span></a></li>
|
||||
<li><a href="/nodes"><strong>节点</strong><span>状态 / 替换 / 上下线</span></a></li>
|
||||
<li><a href="/users"><strong>用户</strong><span>使用状态 / 活跃 / 付费</span></a></li>
|
||||
<li><a href="/audit"><strong>审计</strong><span>操作与事件查询</span></a></li>
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{{define "title"}}用户{{end}}
|
||||
{{define "content"}}
|
||||
<h1>用户使用状态</h1>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{.Data.Stats.TotalUsers}}</b><span>总用户</span></div>
|
||||
<div class="stat"><b>{{.Data.Stats.Active7d}}</b><span>近 7 日活跃</span></div>
|
||||
<div class="stat"><b>{{.Data.Stats.PaidUsers}}</b><span>付费用户</span></div>
|
||||
<div class="stat"><b>{{.Data.Stats.New7d}}</b><span>近 7 日新增</span></div>
|
||||
<div class="stat"><b>{{humanBytes .Data.Stats.Week7dBytes}}</b><span>近 7 日总流量</span></div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/users" class="filters">
|
||||
<label>邮箱<input type="text" name="q" value="{{.Data.Query}}" placeholder="模糊搜索"></label>
|
||||
<label>活跃窗口
|
||||
<select name="active">
|
||||
<option value="7" {{if eq .Data.ActiveDays 7}}selected{{end}}>近 7 天</option>
|
||||
<option value="30" {{if eq .Data.ActiveDays 30}}selected{{end}}>近 30 天</option>
|
||||
<option value="0" {{if eq .Data.ActiveDays 0}}selected{{end}}>全部</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>付费
|
||||
<select name="paid">
|
||||
<option value="" {{if eq .Data.Paid ""}}selected{{end}}>全部</option>
|
||||
<option value="paid" {{if eq .Data.Paid "paid"}}selected{{end}}>付费</option>
|
||||
<option value="free" {{if eq .Data.Paid "free"}}selected{{end}}>免费</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">筛选</button>
|
||||
</form>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>邮箱</th><th>套餐 / 付费</th><th>最近活跃</th><th>首次使用</th>
|
||||
<th>最近设备</th><th>客户端版本</th><th>设备</th><th>近 7 日用量</th><th>累计付费</th><th>邀请</th><th>注册</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Rows}}
|
||||
<tr>
|
||||
<td>
|
||||
{{.Email}}
|
||||
{{if eq .Status "banned"}}<span class="tag danger">已封禁</span>{{end}}
|
||||
{{if .TOTPEnabled}}<span class="pill">2FA</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Plan}}<span class="pill">{{.Plan}}</span>{{else}}<span class="pill free">无</span>{{end}}
|
||||
{{if .HasPaid}}<span class="pill pay">付费</span>{{end}}
|
||||
{{if .SubSource}}<span class="small">{{subSourceZH .SubSource}}</span>{{end}}
|
||||
{{if .SubExpires}}<div class="small">到期 {{fmtTimeP .SubExpires}}{{if gt .DaysLeft 0}}(剩 {{.DaysLeft}} 天){{end}}</div>{{end}}
|
||||
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
|
||||
</td>
|
||||
<td>{{relTime .LastActive}}<div class="small">{{fmtTimeP .LastActive}}</div></td>
|
||||
<td>{{relTime .FirstSeen}}</td>
|
||||
<td>
|
||||
{{if .LastPlatform}}{{.LastPlatform}}{{else}}<span class="muted">—</span>{{end}}
|
||||
{{if .LastDeviceName}}<div class="small">{{.LastDeviceName}}</div>{{end}}
|
||||
</td>
|
||||
<td>{{if .ClientVersion}}<span class="pill">v{{.ClientVersion}}</span>{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td>{{.DeviceCount}}</td>
|
||||
<td class="usage">
|
||||
↑{{humanBytes .WeekBytesUp}} ↓{{humanBytes .WeekBytesDown}}
|
||||
<div class="small">{{minsHuman .WeekMinutes}} · 活跃 {{.WeekActiveDays}}/7 天{{if gt .WeekAdBonusMin 0}} · 广告 +{{.WeekAdBonusMin}}分{{end}}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{money .PayTotalMinor .PayCurrency}}
|
||||
{{if .LastPaidAt}}<div class="small">{{relTime .LastPaidAt}}</div>{{end}}
|
||||
</td>
|
||||
<td>{{if gt .InviteCount 0}}{{.InviteCount}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td>{{relTimeV .Registered}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="11" class="muted">无匹配用户</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
{{if .Data.HasPrev}}<a href="{{.Data.PrevURL}}">上一页</a>{{end}}
|
||||
<span class="muted">共 {{.Data.Total}} 人</span>
|
||||
{{if .Data.HasNext}}<a href="{{.Data.NextURL}}">下一页</a>{{end}}
|
||||
</div>
|
||||
<p class="hint">「最近活跃」取会话/设备的最近活动时间;「近 7 日用量」按日聚合。标签:将到期/已过期=订阅,流失=超 7 天无活动,试用中=尚未付费的试用户。</p>
|
||||
{{end}}
|
||||
@@ -16,6 +16,8 @@ type fakeStore struct {
|
||||
events map[int64][]NodeEvent
|
||||
audits []AuditEntry
|
||||
lastLogin map[int64]time.Time
|
||||
users []UserRow
|
||||
userStats UserStats
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
@@ -110,6 +112,36 @@ func (f *fakeStore) QueryNodeEvents(_ context.Context, nodeID int64, limit int)
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListUsers(_ context.Context, flt UsersFilter) ([]UserRow, int, error) {
|
||||
var matched []UserRow
|
||||
for _, u := range f.users {
|
||||
if flt.Query != "" && !strings.Contains(u.Email, flt.Query) {
|
||||
continue
|
||||
}
|
||||
if flt.Paid == "paid" && !u.HasPaid {
|
||||
continue
|
||||
}
|
||||
if flt.Paid == "free" && u.HasPaid {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, u)
|
||||
}
|
||||
total := len(matched)
|
||||
off := flt.Offset
|
||||
if off > len(matched) {
|
||||
off = len(matched)
|
||||
}
|
||||
matched = matched[off:]
|
||||
if flt.Limit > 0 && len(matched) > flt.Limit {
|
||||
matched = matched[:flt.Limit]
|
||||
}
|
||||
return matched, total, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UserSummary(_ context.Context) (UserStats, error) {
|
||||
return f.userStats, nil
|
||||
}
|
||||
|
||||
// auditFor returns the audit entries whose action matches.
|
||||
func (f *fakeStore) auditFor(action string) []AuditEntry {
|
||||
var out []AuditEntry
|
||||
|
||||
@@ -60,6 +60,66 @@ type AuditFilter struct {
|
||||
Offset int
|
||||
}
|
||||
|
||||
// UserRow is one user's aggregated usage picture for the admin 用户 page.
|
||||
// All fields are read-only projections stitched from users/devices/sessions/
|
||||
// subscriptions/usage_daily/pay_purchases/referrals. Pointer times are NULL-able.
|
||||
type UserRow struct {
|
||||
ID int64
|
||||
Email string
|
||||
Status string // active | banned
|
||||
TOTPEnabled bool
|
||||
Registered time.Time
|
||||
FirstSeen *time.Time // 首次使用 = min(session/device created_at)
|
||||
LastActive *time.Time // 最近活跃 = max(session.last_active, device.last_seen)
|
||||
|
||||
// 当前有效订阅(expires_at 最大的一条)。
|
||||
Plan string // free | pro | team(无订阅为空)
|
||||
SubSource string // trial | code | pay | invite | task
|
||||
SubExpires *time.Time
|
||||
|
||||
// 付费。
|
||||
HasPaid bool // users.first_paid_at 非空
|
||||
FirstPaidAt *time.Time // 首次付费时刻
|
||||
PayTotalMinor int64 // 累计已支付金额(最小单位)
|
||||
PayCurrency string
|
||||
LastPaidAt *time.Time
|
||||
|
||||
// 设备 / 客户端。
|
||||
DeviceCount int
|
||||
LastPlatform string // 最近设备平台 ios|android|windows|macos
|
||||
LastDeviceName string
|
||||
ClientVersion string // 最近客户端版本
|
||||
LastIP string // 最近会话 IP
|
||||
|
||||
// 最近 7 天用量(usage_daily 聚合)。
|
||||
WeekBytesUp int64
|
||||
WeekBytesDown int64
|
||||
WeekMinutes int
|
||||
WeekActiveDays int // 7 天里有用量的天数
|
||||
WeekAdBonusMin int // 看广告解锁分钟合计
|
||||
|
||||
// 增长。
|
||||
InviteCount int // 邀请成功绑定人数(referrals)
|
||||
}
|
||||
|
||||
// UserStats are the summary cards shown atop the 用户 page.
|
||||
type UserStats struct {
|
||||
TotalUsers int
|
||||
Active7d int // 最近 7 天有用量的独立用户
|
||||
PaidUsers int // first_paid_at 非空
|
||||
New7d int // 最近 7 天注册
|
||||
Week7dBytes int64 // 最近 7 天总流量(上+下)
|
||||
}
|
||||
|
||||
// UsersFilter narrows the 用户 list. Empty fields are ignored.
|
||||
type UsersFilter struct {
|
||||
Query string // 邮箱模糊
|
||||
ActiveDays int // 仅显示最近 N 天活跃(0 = 全部)
|
||||
Paid string // ""=全部 | "paid" | "free"
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// BatchSummary is a codes_batches row with aggregate code counts for the
|
||||
// batch list view.
|
||||
type BatchSummary struct {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/config"
|
||||
"github.com/wangjia/pangolin/server/internal/store"
|
||||
)
|
||||
|
||||
// openUsersDB opens an in-memory SQLite with the full schema migrated up. It
|
||||
// proves the 用户 overview queries are portable (no MySQL-only constructs).
|
||||
func openUsersDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := store.MigrateUp(db, "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(q, args...); err != nil {
|
||||
t.Fatalf("exec %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBStore_ListUsers_SQLite(t *testing.T) {
|
||||
db := openUsersDB(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
var proPlan int64
|
||||
if err := db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proPlan); err != nil {
|
||||
t.Fatalf("plans seed missing pro: %v", err)
|
||||
}
|
||||
|
||||
// Two users: a fresh no-activity account and a fully-populated paid VIP.
|
||||
mustExec(t, db, `INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at)
|
||||
VALUES (1,'u1','fresh@x.com','x','dp1','active',?)`, now)
|
||||
mustExec(t, db, `INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at,first_paid_at)
|
||||
VALUES (2,'u2','vip@x.com','x','dp2','active',?,?)`, now.AddDate(0, 0, -40), now.AddDate(0, 0, -5))
|
||||
|
||||
// VIP device + session (drives last-activity / latest-device / client version).
|
||||
mustExec(t, db, `INSERT INTO devices (id,uuid,user_id,name,platform,last_seen,created_at,client_version)
|
||||
VALUES (10,'d10',2,'iPhone','ios',?,?, '1.2.6')`, now.Add(-2*time.Hour), now.AddDate(0, 0, -40))
|
||||
mustExec(t, db, `INSERT INTO sessions (user_id,device_id,refresh_jti,client_ip,client_version,created_at,last_active)
|
||||
VALUES (2,10,'jti-1','203.0.113.9','1.2.6',?,?)`, now.AddDate(0, 0, -40), now.Add(-90*time.Minute))
|
||||
|
||||
// Active subscription (pro / pay) + weekly usage on two distinct days + a paid order + a referral.
|
||||
mustExec(t, db, `INSERT INTO subscriptions (user_id,plan_id,expires_at,source,created_at)
|
||||
VALUES (2,?,?, 'pay', ?)`, proPlan, now.AddDate(0, 0, 20), now.AddDate(0, 0, -5))
|
||||
d1 := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
d2 := now.AddDate(0, 0, -2).Format("2006-01-02")
|
||||
mustExec(t, db, `INSERT INTO usage_daily (user_id,date,bytes_up,bytes_down,minutes_used,ad_bonus_minutes)
|
||||
VALUES (2,?,1048576,3145728,60,0)`, d1)
|
||||
mustExec(t, db, `INSERT INTO usage_daily (user_id,date,bytes_up,bytes_down,minutes_used,ad_bonus_minutes)
|
||||
VALUES (2,?,2097152,4194304,80,15)`, d2)
|
||||
mustExec(t, db, `INSERT INTO pay_purchases (user_id,biz_ref,sku,out_trade_no,method,status,amount_minor,currency,created_at,updated_at,paid_at)
|
||||
VALUES (2,'u2','pro_month','ot-1','alipay','paid',12800,'CNY',?,?,?)`, now.AddDate(0, 0, -5), now.AddDate(0, 0, -5), now.AddDate(0, 0, -5))
|
||||
mustExec(t, db, `INSERT INTO referrals (inviter_id,invitee_id,status,created_at) VALUES (2,1,'bound',?)`, now)
|
||||
|
||||
s := NewDBStore(db)
|
||||
|
||||
// All users, no active-window filter.
|
||||
rows, total, err := s.ListUsers(ctx, UsersFilter{ActiveDays: 0, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("total=%d len=%d; want 2/2", total, len(rows))
|
||||
}
|
||||
var vip *UserRow
|
||||
for i := range rows {
|
||||
if rows[i].ID == 2 {
|
||||
vip = &rows[i]
|
||||
}
|
||||
}
|
||||
if vip == nil {
|
||||
t.Fatal("vip row missing")
|
||||
}
|
||||
if vip.Plan != "pro" || vip.SubSource != "pay" || !vip.HasPaid {
|
||||
t.Errorf("vip sub: plan=%q source=%q paid=%v", vip.Plan, vip.SubSource, vip.HasPaid)
|
||||
}
|
||||
if vip.WeekActiveDays != 2 || vip.WeekMinutes != 140 || vip.WeekAdBonusMin != 15 {
|
||||
t.Errorf("vip weekly: days=%d mins=%d ad=%d", vip.WeekActiveDays, vip.WeekMinutes, vip.WeekAdBonusMin)
|
||||
}
|
||||
if vip.WeekBytesDown != 3145728+4194304 {
|
||||
t.Errorf("vip down bytes=%d", vip.WeekBytesDown)
|
||||
}
|
||||
if vip.LastPlatform != "ios" || vip.ClientVersion != "1.2.6" || vip.DeviceCount != 1 {
|
||||
t.Errorf("vip device: plat=%q ver=%q count=%d", vip.LastPlatform, vip.ClientVersion, vip.DeviceCount)
|
||||
}
|
||||
if vip.PayTotalMinor != 12800 || vip.LastPaidAt == nil || vip.InviteCount != 1 {
|
||||
t.Errorf("vip pay/invite: total=%d lastPaid=%v invites=%d", vip.PayTotalMinor, vip.LastPaidAt, vip.InviteCount)
|
||||
}
|
||||
if vip.LastActive == nil {
|
||||
t.Error("vip LastActive should be set")
|
||||
}
|
||||
|
||||
// Email search narrows to one.
|
||||
only, n, err := s.ListUsers(ctx, UsersFilter{Query: "vip@", Limit: 50})
|
||||
if err != nil || n != 1 || len(only) != 1 || only[0].ID != 2 {
|
||||
t.Fatalf("search vip@: n=%d rows=%d err=%v", n, len(only), err)
|
||||
}
|
||||
|
||||
// Paid filter.
|
||||
paid, _, err := s.ListUsers(ctx, UsersFilter{Paid: "paid", Limit: 50})
|
||||
if err != nil || len(paid) != 1 || paid[0].ID != 2 {
|
||||
t.Fatalf("paid filter: rows=%d err=%v", len(paid), err)
|
||||
}
|
||||
|
||||
// Summary cards.
|
||||
st, err := s.UserSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UserSummary: %v", err)
|
||||
}
|
||||
if st.TotalUsers != 2 || st.PaidUsers != 1 || st.Active7d != 1 {
|
||||
t.Errorf("summary: total=%d paid=%d active=%d", st.TotalUsers, st.PaidUsers, st.Active7d)
|
||||
}
|
||||
if st.Week7dBytes != 1048576+3145728+2097152+4194304 {
|
||||
t.Errorf("summary week bytes=%d", st.Week7dBytes)
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,18 @@ import { SITE } from '../config/site';
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
// href 缺省 = 本轮未接入下载(iOS 走 TestFlight / Linux),按钮渲染为禁用态占位。
|
||||
// Android + Windows + macOS 已由客户端 CI 产出真实产物并部署到 /downloads。
|
||||
const plats: { icon: string; name: string; ver: string; href?: string }[] = [
|
||||
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
|
||||
// href 缺省 = 未接入下载 → 禁用「敬请期待」占位。
|
||||
// iOS/iPad 合成一张卡、两个按钮:「测试版」(TestFlight 公开链接) 现已可装,
|
||||
// 「正式版」(App Store) 禁用,按钮内带「?」——悬浮提示「美区即将上线,需美区 Apple ID」。
|
||||
type Action = { label: string; href?: string; hint?: string };
|
||||
const plats: { icon: string; name: string; ver: string; href?: string; badge?: string; note?: string; actions?: Action[] }[] = [
|
||||
{
|
||||
icon: 'smartphone', name: 'iOS / iPad', ver: 'iOS / iPadOS 15+',
|
||||
actions: [
|
||||
{ label: t('dl.beta'), href: SITE.downloads.ios },
|
||||
{ label: t('dl.stable'), hint: `${t('dl.us_soon')} · ${t('dl.us_id')}` },
|
||||
],
|
||||
},
|
||||
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android },
|
||||
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+', href: SITE.downloads.macos },
|
||||
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11', href: SITE.downloads.windows },
|
||||
@@ -27,9 +35,30 @@ const plats: { icon: string; name: string; ver: string; href?: string }[] = [
|
||||
{plats.map((p) => (
|
||||
<div class="dl">
|
||||
<div class="ico"><Icon name={p.icon} /></div>
|
||||
<div class="pn">{p.name}</div>
|
||||
<div class="pn">
|
||||
<span>{p.name}</span>
|
||||
{p.badge && <span class="badge">{p.badge}</span>}
|
||||
{p.note && <span class="q" title={p.note} tabindex="0" aria-label={p.note}>?</span>}
|
||||
</div>
|
||||
<div class="pv">{p.ver}</div>
|
||||
{p.href ? (
|
||||
{p.actions ? (
|
||||
<div class="btns">
|
||||
{p.actions.map((a) => (
|
||||
a.href ? (
|
||||
<a class="b2 beta" href={a.href}><span>{a.label}</span></a>
|
||||
) : (
|
||||
<span class="b2 disabled" aria-disabled="true">
|
||||
<span>{a.label}</span>
|
||||
{a.hint && (
|
||||
<span class="q" tabindex="0" aria-label={a.hint}>
|
||||
?<span class="tip" role="tooltip">{a.hint}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : p.href ? (
|
||||
<a class="gb" href={p.href}><Icon name="download" /><span>{t('dl.get')}</span></a>
|
||||
) : (
|
||||
<span class="gb disabled" aria-disabled="true"><Icon name="download" /><span>{t('dl.soon')}</span></span>
|
||||
@@ -39,3 +68,56 @@ const plats: { icon: string; name: string; ver: string; href?: string }[] = [
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* iOS/iPad 测试版徽标 + 「?需美区 Apple ID」提示;颜色全走设计 token,不硬编码。 */
|
||||
.dl .pn { display: inline-flex; align-items: center; justify-content: center; gap: .38rem; flex-wrap: wrap; }
|
||||
.dl .badge {
|
||||
font-size: 11px; font-weight: 700; line-height: 1;
|
||||
padding: .22em .5em; border-radius: 999px;
|
||||
background: var(--accent-subtle); color: var(--accent);
|
||||
font-family: var(--font-mono); letter-spacing: .02em;
|
||||
}
|
||||
.dl .q {
|
||||
position: relative;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
font-size: 11px; font-weight: 700; cursor: default;
|
||||
background: var(--bg-subtle); color: var(--fg3);
|
||||
}
|
||||
/* 自定义提示气泡:走设计 token,悬浮/聚焦「?」时上浮显示,带小箭头。 */
|
||||
.dl .q .tip {
|
||||
position: absolute; bottom: calc(100% + 9px); left: 50%;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
width: max-content; max-width: 210px; white-space: normal;
|
||||
padding: 8px 11px; border-radius: var(--radius-md, 10px);
|
||||
background: var(--fg1); color: var(--bg);
|
||||
font-family: var(--font-sans); font-size: 12px; font-weight: 500;
|
||||
line-height: 1.45; text-align: center; letter-spacing: 0;
|
||||
box-shadow: var(--shadow-md, 0 6px 20px rgba(45,30,20,.18));
|
||||
opacity: 0; visibility: hidden; pointer-events: none; z-index: 20;
|
||||
transition: opacity var(--dur-base, .18s) var(--ease-out, ease),
|
||||
transform var(--dur-base, .18s) var(--ease-out, ease),
|
||||
visibility var(--dur-base, .18s);
|
||||
}
|
||||
.dl .q .tip::after {
|
||||
content: ""; position: absolute; top: 100%; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent; border-top-color: var(--fg1);
|
||||
}
|
||||
.dl .q:hover .tip, .dl .q:focus-visible .tip {
|
||||
opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
/* iOS/iPad 单卡双按钮:测试版(实心 accent 可点)+ 正式版(描边禁用,内含「?」悬浮提示)。 */
|
||||
.dl .btns { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 2px; }
|
||||
.dl .b2 {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 8px 15px; border-radius: 999px; line-height: 1.15;
|
||||
font-size: 13px; font-weight: 600; text-decoration: none;
|
||||
border: 1px solid var(--accent); color: var(--accent);
|
||||
}
|
||||
.dl .b2.beta { background: var(--accent); color: var(--fg-on-accent); border-color: var(--accent); }
|
||||
/* 禁用态:不导航(本就是 span)+ 默认光标,但不禁用指针事件——否则内含「?」的 title 悬浮失效。 */
|
||||
.dl .b2.disabled { border-color: var(--border); color: var(--fg3); cursor: default; }
|
||||
.dl .b2 .q { background: transparent; border: 1px solid var(--border); }
|
||||
</style>
|
||||
|
||||
@@ -41,5 +41,7 @@ export const SITE = {
|
||||
android: 'https://api.yanmeiai.com/downloads/pangolin-android.apk',
|
||||
windows: 'https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe',
|
||||
macos: 'https://api.yanmeiai.com/downloads/pangolin-macos-x64.zip',
|
||||
// iOS 无直接下载文件 → TestFlight 公测公开链接(点按钮直接跳 TestFlight 安装/更新)。
|
||||
ios: 'https://testflight.apple.com/join/6HFfw8Jc',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -137,6 +137,10 @@ export const STRINGS: Record<string, Record<Lang, string>> = {
|
||||
'dl.sub': { zh: '一个账户,所有设备同步。下载即用,无需配置。', en: 'One account syncs every device. Download and go.', ja: '一つのアカウントで全デバイスを同期。ダウンロードしてすぐ使えます。', ko: '하나의 계정으로 모든 기기를 동기화. 내려받아 바로 사용.', ru: 'Один аккаунт синхронизирует все устройства. Скачал — и в путь.', es: 'Una cuenta sincroniza todos tus dispositivos. Descarga y listo.' },
|
||||
'dl.get': { zh: '下载', en: 'Download', ja: 'ダウンロード', ko: '다운로드', ru: 'Скачать', es: 'Descargar' },
|
||||
'dl.soon': { zh: '敬请期待', en: 'Coming soon', ja: '近日公開', ko: '출시 예정', ru: 'Скоро', es: 'Próximamente' },
|
||||
'dl.beta': { zh: '测试版', en: 'Beta', ja: 'ベータ版', ko: '베타', ru: 'Бета', es: 'Beta' },
|
||||
'dl.stable': { zh: '正式版', en: 'Stable', ja: '正式版', ko: '정식판', ru: 'Релиз', es: 'Estable' },
|
||||
'dl.us_id': { zh: '需美区 Apple ID', en: 'Requires a US Apple ID', ja: '米国 Apple ID が必要', ko: '미국 Apple ID 필요', ru: 'Нужен Apple ID (США)', es: 'Requiere un Apple ID de EE. UU.' },
|
||||
'dl.us_soon': { zh: '美区即将上线', en: 'Coming to US App Store', ja: '米国 App Store で近日公開', ko: '미국 App Store 출시 예정', ru: 'Скоро в App Store (США)', es: 'Pronto en App Store (EE. UU.)' },
|
||||
|
||||
'docs.eyebrow': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' },
|
||||
'docs.h': { zh: '需要帮助?都在这里', en: 'Need help? It’s all here', ja: 'お困りですか?すべてここに', ko: '도움이 필요하세요? 모두 여기에', ru: 'Нужна помощь? Всё здесь', es: '¿Necesitas ayuda? Todo está aquí' },
|
||||
|
||||
Reference in New Issue
Block a user