Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f37f1f3b59 | |||
| c9e266b89a |
@@ -228,7 +228,9 @@ jobs:
|
||||
-v "$HOME/.cache/pangolin-ci/gomod:/go/pkg/mod" \
|
||||
-v "$HOME/.cache/pangolin-ci/gobuild:/root/.cache/go-build" \
|
||||
golang:1.25 \
|
||||
bash -c "apt-get update -qq && apt-get install -y -qq openssl curl python3 >/dev/null 2>&1 && bash scripts/e2e-smoke.sh"
|
||||
bash -c "bash scripts/e2e-smoke.sh"
|
||||
# 注:openssl/curl/python3 已在 golang:1.25 镜像内,无需 apt 安装
|
||||
# (原 apt-get 会走 Docker Desktop 代理→本机 clash 死口,徒增网络脆性)。
|
||||
|
||||
# ── Job 10: Go 集成测试 (L2:真 mysql8/redis 经 testcontainers)──────────
|
||||
# 跨库可移植(支柱 3)+ 按租户流量记账(usage)+ 配额(devices)+ 兑换(codes)+
|
||||
|
||||
Vendored
+1
@@ -57,6 +57,7 @@
|
||||
'compass': '<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/>',
|
||||
'chart-no-axes-column': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
|
||||
'chart': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
|
||||
'home': '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10"/>',
|
||||
'map-pin': '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
|
||||
'laptop': '<path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16"/>',
|
||||
'monitor-smartphone': '<path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"/><path d="M10 19v-3.96 3.15"/><path d="M7 19h5"/><rect width="6" height="10" x="16" y="12" rx="2"/>',
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestIntegration_FullChain(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Register → trial subscription must exist for 7 days.
|
||||
pair, apiErr := svc.Register(ctx, email, code, pw)
|
||||
pair, apiErr := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Register: %v", apiErr)
|
||||
}
|
||||
@@ -170,12 +170,12 @@ func TestIntegration_FullChain(t *testing.T) {
|
||||
}
|
||||
// Force a fresh code regardless of rate limit.
|
||||
_ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err()
|
||||
if _, e := svc.Register(ctx, email, code, pw); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||
if _, e := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid (anti-enumeration), got %v", e)
|
||||
}
|
||||
|
||||
// 4. Login.
|
||||
loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7")
|
||||
loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7", DeviceMeta{})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Login: %v", apiErr)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,21 @@ package devices
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// ctxKey is a private type for context keys to avoid collisions.
|
||||
type ctxKey string
|
||||
|
||||
// CtxKeyUserID is the context key under which the authenticated user's
|
||||
// internal int64 ID is stored by the JWT auth middleware (module #2).
|
||||
// internal int64 ID is stored by the JWT auth middleware (auth.RequireAuth).
|
||||
//
|
||||
// It mirrors the key used by the codes module so that, once the auth
|
||||
// middleware lands, a single canonical key can be reconciled across modules.
|
||||
const CtxKeyUserID ctxKey = "user_id"
|
||||
// It is the single canonical key shared with the codes and auth modules
|
||||
// (auth.UserIDFromContext reads codes.CtxKeyUserID). Aliasing it here — rather
|
||||
// than declaring a distinct devices.ctxKey("user_id") — ensures the devices
|
||||
// middleware/handlers resolve the same value the auth middleware injects.
|
||||
const CtxKeyUserID = codes.CtxKeyUserID
|
||||
|
||||
// ctxKeyPlan is the context key under which the resolved subscription Plan is
|
||||
// stored by SubscriptionMiddleware.
|
||||
|
||||
@@ -117,7 +117,7 @@ func applySchema(db *sql.DB) error {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
|
||||
`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
|
||||
VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`,
|
||||
VALUES ('free', 1, 10, TRUE), ('pro', 3, NULL, FALSE), ('team', 10, NULL, FALSE)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
|
||||
@@ -108,6 +108,7 @@ func applySchema(db *sql.DB) error {
|
||||
bytes_down BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
minutes_used INT NOT NULL DEFAULT 0,
|
||||
ad_unlocked_at DATETIME(6) NULL,
|
||||
ad_bonus_minutes INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS usage_hourly (
|
||||
@@ -433,11 +434,16 @@ func TestIntAdsUnlockAccumulates(t *testing.T) {
|
||||
t.Errorf("remaining after 2 ads=%d, want 30", rem)
|
||||
}
|
||||
|
||||
// Exactly one unlock timestamp.
|
||||
var n int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM usage_daily WHERE user_id=? AND ad_unlocked_at IS NOT NULL`, uid).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 unlocked day, got %d", n)
|
||||
// Additive model: the two ads accumulate into a single day row's
|
||||
// ad_bonus_minutes (10 + 10 = 20). (ad_unlocked_at is legacy from the old
|
||||
// per-day boolean unlock and is no longer stamped by UnlockAd.)
|
||||
var rows, bonus int
|
||||
db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(ad_bonus_minutes),0) FROM usage_daily WHERE user_id=?`, uid).Scan(&rows, &bonus)
|
||||
if rows != 1 {
|
||||
t.Errorf("expected 1 usage_daily row, got %d", rows)
|
||||
}
|
||||
if bonus != 20 {
|
||||
t.Errorf("expected ad_bonus_minutes=20, got %d", bonus)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ function checkWebTokenSync(label, webPath) {
|
||||
// usercenter: export const LUCIDE: Record<string,string> = { 'name': '<path/>', ... }
|
||||
const ucJs = read('web/usercenter/components/icons.tsx');
|
||||
const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? '';
|
||||
for (const [, id, body] of ucBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) {
|
||||
// 键可能带引号('refresh-cw')或裸标识符(home)—— 两种都匹配(裸键此前被漏检)。
|
||||
for (const [, id, body] of ucBody.matchAll(/['"]?([\w-]+)['"]?\s*:\s*'([^']*)'/g)) {
|
||||
if (!(id in protoIcons)) {
|
||||
problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`);
|
||||
} else if (protoIcons[id] !== body) {
|
||||
|
||||
@@ -152,6 +152,10 @@ export default function UserCenter() {
|
||||
</div>
|
||||
{!mobile && <nav style={{ display: 'flex', gap: 4, flex: 1 }}>{navBtns}</nav>}
|
||||
{mobile && <div style={{ flex: 1 }} />}
|
||||
<a href="/" title={t('backHome')} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, textDecoration: 'none', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6 }}>
|
||||
<Icon name="home" size={15} color="var(--fg3)" />
|
||||
{!mobile && t('backHome')}
|
||||
</a>
|
||||
<button onClick={toggleTheme} aria-label="theme" title="theme" style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', padding: 6, display: 'flex' }}>
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} color="var(--fg3)" />
|
||||
</button>
|
||||
|
||||
@@ -25,6 +25,7 @@ export const LUCIDE: Record<string, string> = {
|
||||
'shopping-bag': '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||
'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
home: '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10"/>',
|
||||
gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/>',
|
||||
smartphone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
|
||||
@@ -12,6 +12,7 @@ export const STRINGS: Record<string, Entry> = {
|
||||
navInvite: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' },
|
||||
navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
|
||||
signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
|
||||
backHome: { zh: '返回主页', en: 'Home', ja: 'ホーム', ko: '홈', ru: 'На главную', es: 'Inicio' },
|
||||
|
||||
/* login */
|
||||
loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' },
|
||||
|
||||
@@ -30,8 +30,18 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const langRef = useRef(null);
|
||||
|
||||
// 与用户中心同源:登录后 localStorage 存 pg_uc_refresh → 显示「用户中心」入口。
|
||||
useEffect(() => {
|
||||
try {
|
||||
setLoggedIn(!!localStorage.getItem('pg_uc_refresh'));
|
||||
} catch {
|
||||
setLoggedIn(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return;
|
||||
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
|
||||
@@ -75,7 +85,7 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
))}
|
||||
</nav>
|
||||
<div class="right">
|
||||
<div ref={langRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div ref={langRef} class="langwrap">
|
||||
<button
|
||||
type="button"
|
||||
class="langsel"
|
||||
@@ -83,21 +93,18 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={langOpen}
|
||||
aria-label="Language"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
|
||||
style={{ transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
|
||||
<svg class="caret" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{langOpen && (
|
||||
<div role="listbox" style={{ position: 'absolute', top: 'calc(100% + 6px)', right: 0, minWidth: 132, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)', padding: 5, zIndex: 60, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div class="langmenu" role="listbox">
|
||||
{langs.map(([code, label]) => {
|
||||
const on = code === lang;
|
||||
return (
|
||||
<a key={code} role="option" aria-selected={on} href={langHref(code)}
|
||||
style={{ textAlign: 'left', textDecoration: 'none', background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent', color: on ? 'var(--accent)' : 'var(--fg1)', fontWeight: on ? 700 : 500, fontFamily: 'var(--font-sans)', fontSize: 13, padding: '8px 11px', borderRadius: 8, whiteSpace: 'nowrap' }}>
|
||||
<a key={code} role="option" aria-selected={on} href={langHref(code)}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
@@ -105,7 +112,11 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||
{loggedIn ? (
|
||||
<a class="linklogin" href={SITE.usercenter}>{t.center}</a>
|
||||
) : (
|
||||
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||
)}
|
||||
<a class="btn btn-primary" href="#download">
|
||||
<Download />
|
||||
<span>{t.get}</span>
|
||||
|
||||
@@ -38,6 +38,7 @@ export const STRINGS: Record<string, Record<Lang, string>> = {
|
||||
'nav.docs': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' },
|
||||
'nav.blog': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' },
|
||||
'nav.login': { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' },
|
||||
'nav.center': { zh: '用户中心', en: 'Account', ja: 'アカウント', ko: '계정', ru: 'Личный кабинет', es: 'Mi cuenta' },
|
||||
'nav.get': { zh: '立即下载', en: 'Get the app', ja: 'アプリを入手', ko: '앱 받기', ru: 'Получить приложение', es: 'Obtener la app' },
|
||||
|
||||
'hero.eyebrow': { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless', ja: '高速 · 安定 · 快適', ko: '빠름 · 안정 · 간편', ru: 'Быстро · Стабильно · Без забот', es: 'Rápido · Estable · Sin complicaciones' },
|
||||
|
||||
@@ -56,6 +56,7 @@ const headerT = {
|
||||
docs: t('nav.docs'),
|
||||
blog: t('nav.blog'),
|
||||
login: t('nav.login'),
|
||||
center: t('nav.center'),
|
||||
get: t('nav.get'),
|
||||
suBtn: t('su.btn'),
|
||||
};
|
||||
|
||||
@@ -42,6 +42,17 @@ img,svg{display:block}
|
||||
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
|
||||
.linklogin:hover{color:var(--accent)}
|
||||
|
||||
/* ---------- language dropdown ---------- */
|
||||
.langwrap{position:relative;display:inline-block}
|
||||
.langsel{display:inline-flex;align-items:center;gap:6px;border:1.5px solid var(--border-strong);border-radius:var(--radius-full);padding:8px 14px;background:var(--surface);color:var(--fg1);font-family:var(--font-sans);font-size:14px;font-weight:600;cursor:pointer;transition:border-color var(--dur-fast) var(--ease-out),color var(--dur-fast) var(--ease-out)}
|
||||
.langsel:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.langsel .caret{width:11px;height:11px;transition:transform 140ms var(--ease-out)}
|
||||
.langsel[aria-expanded="true"] .caret{transform:rotate(180deg)}
|
||||
.langmenu{position:absolute;top:calc(100% + 6px);right:0;min-width:160px;display:flex;flex-direction:column;gap:1px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:5px;z-index:60}
|
||||
.langmenu a{text-align:left;text-decoration:none;font-family:var(--font-sans);font-size:13px;font-weight:500;color:var(--fg1);background:transparent;padding:8px 11px;border-radius:var(--radius-sm);white-space:nowrap;transition:background var(--dur-fast) var(--ease-out)}
|
||||
.langmenu a:hover{background:var(--bg-subtle)}
|
||||
.langmenu a[aria-selected="true"]{color:var(--accent);background:var(--accent-subtle);font-weight:700}
|
||||
|
||||
/* mobile nav */
|
||||
.menu-btn{display:none;border:none;background:transparent;cursor:pointer;padding:7px;border-radius:var(--radius-sm);color:var(--fg1)}
|
||||
.menu-btn svg{width:22px;height:22px}
|
||||
|
||||
Reference in New Issue
Block a user