Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ceef18ace | |||
| a399b3d701 | |||
| e957894766 | |||
| a4c51203fb | |||
| 03f3c9228f | |||
| 2a7eec02fb | |||
| a392d18a6a | |||
| a913fe56fb | |||
| 18fb5177be |
@@ -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 挂载会失败)。
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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