Compare commits

...

11 Commits

Author SHA1 Message Date
wangjia 5ceef18ace Merge pull request 'fix(ci): deploy-server/site Checkout 加 fetch-depth:1(治间歇性失败)' (#19) from fix/ci-checkout-shallow into main
Deploy Server / deploy-server (push) Successful in 3m37s
Reviewed-on: #19
2026-09-13 02:18:09 +00:00
wangjia a399b3d701 fix(ci): deploy-server/site 的 Checkout 加 fetch-depth:1(治间歇性 Checkout 失败)
根因(runner 日志实证):actions/checkout 从 GITHUB_SERVER_URL=http://git.51yanmei.com
克隆——该域名解析到 ali 公网、发夹弯绕回 NAS,慢且间歇 reset;而 runner↔gitea 的
API 通信走 LAN 192.168.3.200:3000 是稳的。deploy-server/site 用 full clone(全历史
几十 MB)走那条抖动链路必挂在 Checkout;deploy-client 早已加 fetch-depth:1 故其发版能过。

给 deploy-server/site 的 Checkout 补上同样的 fetch-depth:1,数据量降到几 MB,
大幅提升成功率(与 deploy-client 一致)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 23:13:08 +08:00
wangjia e957894766 Merge pull request 'feat(admin): 用户使用状态页(MVP,内部观测)' (#18) from feat/admin-user-usage into main
Deploy Server / deploy-server (push) Failing after 11m5s
Reviewed-on: #18
2026-09-12 13:37:20 +00:00
wangjia a4c51203fb feat(admin): 用户页「客户端版本」提为独立列
原本客户端版本作为小字挂在「最近设备」列下,易漏。提成独立列(药丸样式),
方便扫版本分布/自动更新渗透率。测试补断言版本列渲染。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 21:35:49 +08:00
wangjia 03f3c9228f feat(admin): 新增「用户使用状态」页(MVP,内部观测)
管理后台加一个只读的用户使用状态视图,方便观察软件使用状况:

- 顶部汇总卡:总用户 / 近7日活跃 / 付费用户 / 近7日新增 / 近7日总流量。
- 主表(按最近活跃倒序,默认近30天活跃,支持邮箱搜索+活跃窗口+付费筛选、分页):
  邮箱(+封禁/2FA)、套餐+付费来源+到期剩余、最近活跃、首次使用、最近设备+客户端版本、
  设备数、近7日用量(↑↓流量·时长·活跃天数·广告解锁)、累计付费、邀请人数、注册。
- 派生观测标签:将到期/已过期(订阅)、流失(超7天无活动)、试用中。

实现:
- store.ListUsers(分页主查询 + 5 个按 user_id 批量补全:订阅/周用量/最近设备/最近会话/付费)
  与 UserSummary;全程可移植 SQL(时间边界 Go 端算好传 ?,无 MySQL 专属构造)。
- 周聚合取 usage_daily(DATE 粒度),活跃天数=COUNT(DISTINCT date)。
- SQLite 派生时间列返回字符串(modernc 怪癖)→ asTimePtr 统一归一,兼容 MySQL 的 time.Time。
- 路由 GET /users;导航与概览卡补入口;新增展示模板与函数(相对时间/字节/分钟/金额)。

测试:handler 渲染(空指针行+满字段标签)+ SQLite 真库端到端(ListUsers/UserSummary/搜索/筛选)。
可移植 SQL 闸通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 20:27:20 +08:00
wangjia 2a7eec02fb Merge pull request 'feat(web/client): iOS 下载/更新指向 TestFlight 公测链接' (#17) from worktree-fix-sysext-network-ent into main
Deploy Site / deploy-site (push) Failing after 11m29s
Reviewed-on: #17
2026-09-12 00:37:44 +00:00
wangjia a392d18a6a feat(web): iOS/iPad 下载卡合一 + 双按钮 + 正式版「?」气泡提示
- 三张 iOS/iPad 卡合成一张:测试版(TestFlight,实心可点)+ 正式版(App Store,描边禁用)双按钮。
- 「?」移入正式版按钮内,自定义 tooltip 气泡(走设计 token,上浮淡入+小箭头)替代原生 title,
  悬浮/聚焦显示「美区即将上线 · 需美区 Apple ID」;光标不再变问号。
- 新增 i18n 文案 dl.us_soon(6 语)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 16:33:16 +08:00
wangjia a913fe56fb feat(web): 下载区补 iPad + iOS 分测试版/正式版 + 需美区ID提示
按需求调整官网下载卡片:
- 补 iPad 卡片(tablet 图标,universal app→同 TestFlight 公测链接)。
- iOS/iPad 明确分「测试版」(TestFlight,可点)与「正式版」(App Store,
  敬请期待占位)——加 badge 标注(dl.beta/dl.stable i18n,6 语言)。
- 测试版卡加「?」悬浮提示「需美区 Apple ID」(dl.us_id i18n)。
- badge/「?」样式全走设计 token(--accent-subtle/--accent/--bg-subtle/
  --fg3),不硬编码颜色。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 09:02:43 +08:00
wangjia 18fb5177be feat(web/client): iOS 下载/更新指向 TestFlight 公测链接
公测公开链接 https://testflight.apple.com/join/6HFfw8Jc。
- 官网 Download:site.ts 加 downloads.ios + Download.astro iOS 按钮启用
  (原为禁用占位),点击直跳 TestFlight;ver 顺带改 iOS 15+(对齐最低版本)。
- 客户端自更新:清单模板 deploy/single-node/version.yaml 的
  download_urls.ios 从空填成 TF 链接 → 客户端「有更新」按钮直跳 TestFlight。
- pangolin1 现行 /etc/pangolin/version.yaml 已同步改(/version 已反映),
  现有用户即时生效。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 08:55:49 +08:00
wangjia 80d88f185d Merge pull request 'fix(ci): checkout 浅克隆 + sing-box 重试(修外网 frps 隧道 Checkout 超时)' (#16) from worktree-fix-sysext-network-ent into main
Deploy Client / build-windows (push) Successful in 2m22s
Deploy Client / deploy-web (push) Successful in 4m29s
Deploy Client / build-android (push) Successful in 4m33s
Deploy Client / build-macos (push) Successful in 6m27s
Deploy Client / build-ios (push) Successful in 3m16s
Deploy Client / release-deploy (push) Successful in 2m3s
Reviewed-on: #16
2026-09-08 04:38:03 +00:00
wangjia cd1ef98b27 fix(ci): checkout 浅克隆 + sing-box clone 重试(外网走 frps 稳定性 A)
外网 runner 的 git fetch 经 ali frps 隧道偶发超时(run#289/290 的 Checkout
卡死即此)。A 层加固:
- deploy-client.yml 全部 actions/checkout 加 fetch-depth:1 —— 从拉全历史
  降到一层,数据骤减,直接打掉 Checkout 步超时根因(外网/在家都受益)。
- build-libbox.sh sing-box clone(已 --depth 1)加 3 次重试 —— 另一大 fetch
  抖了自动重来。
checkout 步显式重试(需替换 actions/checkout)暂缓,先看浅克隆后失败率;
仍高再上 checkout 重试 + frps 隧道调优(B)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEVUXAbFT6bF1Qw27RHWoD
2026-09-08 12:33:17 +08:00
20 changed files with 1073 additions and 9 deletions
+24
View File
@@ -40,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:
@@ -71,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
@@ -113,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 密钥集。
@@ -158,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
@@ -191,6 +207,10 @@ jobs:
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
@@ -226,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)。
+5
View File
@@ -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)。
+5
View File
@@ -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 挂载会失败)。
+2 -2
View File
@@ -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: []
+8 -1
View File
@@ -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 编核心库"
+111
View File
@@ -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
// --------------------------------------------------------------------------
+34
View File
@@ -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)
}
}
}
+1
View File
@@ -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
+12
View File
@@ -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); }
+383
View File
@@ -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
}
+84
View File
@@ -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}}
+32
View File
@@ -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
View File
@@ -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 {
+131
View File
@@ -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)
}
}
+88 -6
View File
@@ -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>
+2
View File
@@ -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;
+4
View File
@@ -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? Its all here', ja: 'お困りですか?すべてここに', ko: '도움이 필요하세요? 모두 여기에', ru: 'Нужна помощь? Всё здесь', es: '¿Necesitas ayuda? Todo está aquí' },