Compare commits
5 Commits
ed99e2943e
...
a62a2b1797
| Author | SHA1 | Date | |
|---|---|---|---|
| a62a2b1797 | |||
| ec4b3e0f22 | |||
| 301e13f477 | |||
| 3acd3ca59d | |||
| 1e102a5a79 |
@@ -166,7 +166,15 @@ Future<void> _macInstall(String zipPath) async {
|
||||
// 写 helper、分离启动、退出让其换装重启。参数:PID/现.app/新.app/暂存/zip。
|
||||
final helper = '$staging/pangolin-update.sh';
|
||||
await File(helper).writeAsString(_macUpdateHelper);
|
||||
await Process.run('/bin/chmod', <String>['+x', helper]);
|
||||
final chmod = await Process.run('/bin/chmod', <String>['+x', helper]);
|
||||
if (chmod.exitCode != 0) {
|
||||
// helper 跑不起来:清掉半截暂存(解压出的新 app + 不可执行的 helper),回退访达手动流程。
|
||||
try {
|
||||
await Directory(staging).delete(recursive: true);
|
||||
} catch (_) {}
|
||||
await _macReveal(zipPath);
|
||||
return;
|
||||
}
|
||||
await Process.start(
|
||||
'/bin/sh',
|
||||
<String>[helper, '$pid', appPath, newApp, staging, zipPath],
|
||||
@@ -195,8 +203,12 @@ Future<String?> _findDotApp(String dir) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// macOS 自更新 helper 脚本(分离进程跑):等主 app 退出 → 原子换 bundle(失败回滚,
|
||||
/// 属主非本人则 osascript 提权)→ 清 quarantine → open 重启。
|
||||
/// macOS 自更新 helper 脚本(分离进程跑):等主 app 完全退出(超时+宽限后仍未退出则放弃,
|
||||
/// 不硬动运行中的 bundle)→ codesign 验签新版(不过拒绝换装,Developer-ID 完整性闸)→
|
||||
/// 原子换 bundle(失败必回滚并校验回滚是否真的成功,不假设 mv 一定成功;仍失败则 osascript
|
||||
/// 提权,提权脚本自身具备「从 $BACKUP/$NEW_APP 自愈」逻辑,不假设 $APP 一定还在)→ 终检
|
||||
/// $APP 确实存在才继续 → 清 quarantine → open 重启(检退出码,失败记日志尽力而为)。
|
||||
/// 任何失败组合下都保证 /Applications 不会出现「app 消失、零提示」。
|
||||
const String _macUpdateHelper = r'''#!/bin/sh
|
||||
# Pangolin macOS 自更新 helper —— 由 app_updater.dart 生成、分离进程启动。
|
||||
# 参数:PID(主app进程) APP(现.app) NEW_APP(解压出的新.app) STAGING(暂存目录) ZIP(下载的zip,清理用)
|
||||
@@ -204,52 +216,111 @@ PID="$1"; APP="$2"; NEW_APP="$3"; STAGING="$4"; ZIP="$5"
|
||||
exec >>"$STAGING/update.log" 2>&1
|
||||
echo "[helper] start pid=$PID app=$APP new=$NEW_APP"
|
||||
|
||||
# 1. 等主 app 完全退出(最多 ~30s 兜底)
|
||||
BACKUP="${APP}.pangolin-old"
|
||||
|
||||
# 放弃自动换装:访达定位新版供用户手动拖,保留现场(STAGING/BACKUP)便于排查/手动恢复。
|
||||
fallback_reveal() {
|
||||
echo "[helper] fallback: $1"
|
||||
/usr/bin/open -R "$NEW_APP" 2>/dev/null
|
||||
}
|
||||
|
||||
# 1. 等主 app 完全退出(最多 ~30s;超时再给 5s 宽限;仍未退出则放弃,不硬动运行中的 bundle)
|
||||
i=0
|
||||
while kill -0 "$PID" 2>/dev/null; do
|
||||
sleep 0.3; i=$((i+1))
|
||||
[ "$i" -gt 100 ] && { echo "[helper] wait timeout"; break; }
|
||||
[ "$i" -gt 100 ] && break
|
||||
done
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "[helper] wait timeout at ~30s, grace +5s"
|
||||
sleep 5
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
fallback_reveal "app still running after grace period, refusing to touch bundle"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
sleep 0.5
|
||||
|
||||
BACKUP="${APP}.pangolin-old"
|
||||
# 2. 签名验签闸:换装前必须验证新版签名完整(Developer-ID 分发完整性),不过拒绝换装
|
||||
if ! /usr/bin/codesign --verify --deep --strict "$NEW_APP" 2>/dev/null; then
|
||||
fallback_reveal "codesign verify failed on new app, refusing swap"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 静默换:mv 旧→备份 → mv 新→原位;任何一步失败自动回滚(admin 用户对 /Applications 可写 → 无弹窗)
|
||||
# 3. 静默换装(无提权):自愈式 swap —— 若 $APP 因上次失败缺失但 $BACKUP 还在,先自愈复原再换;
|
||||
# 换装失败一律回滚,并校验回滚是否真的成功(不假设 mv 一定成功)。
|
||||
# 返回码:0=成功 1=失败但 $APP 完好(未动/已回滚) 2=$APP 缺失且无 $BACKUP 可自愈 3=回滚也失败($APP 缺失,严重)
|
||||
swap_plain() {
|
||||
if [ ! -d "$APP" ] && [ -d "$BACKUP" ]; then
|
||||
/bin/mv "$BACKUP" "$APP" 2>/dev/null
|
||||
fi
|
||||
if [ ! -d "$APP" ]; then
|
||||
return 2
|
||||
fi
|
||||
/bin/rm -rf "$BACKUP" 2>/dev/null
|
||||
/bin/mv "$APP" "$BACKUP" 2>/dev/null || return 1
|
||||
/bin/mv "$NEW_APP" "$APP" 2>/dev/null || { /bin/mv "$BACKUP" "$APP" 2>/dev/null; return 1; }
|
||||
/bin/rm -rf "$BACKUP" 2>/dev/null
|
||||
return 0
|
||||
if /bin/mv "$NEW_APP" "$APP" 2>/dev/null; then
|
||||
/bin/rm -rf "$BACKUP" 2>/dev/null
|
||||
return 0
|
||||
fi
|
||||
/bin/mv "$BACKUP" "$APP" 2>/dev/null
|
||||
if [ -d "$APP" ]; then
|
||||
return 1
|
||||
fi
|
||||
return 3
|
||||
}
|
||||
|
||||
if swap_plain; then
|
||||
swap_plain
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "[helper] swap_plain ok"
|
||||
else
|
||||
echo "[helper] swap_plain failed -> osascript 提权"
|
||||
# 3. 提权兜底:把带路径的换装命令写进 root 脚本,osascript 弹一次系统密码框以 root 跑
|
||||
echo "[helper] swap_plain failed (rc=$rc) -> osascript 提权自愈"
|
||||
# 4. 提权兜底:ROOT_SH 自身自愈,不假设 $APP 一定存在——
|
||||
# 优先从 $BACKUP 复原、再正常 swap;$APP/$BACKUP 都没了则以 $NEW_APP 直接就位兜底。
|
||||
ROOT_SH="$STAGING/swap-root.sh"
|
||||
{
|
||||
echo '#!/bin/sh'
|
||||
echo "/bin/rm -rf \"$BACKUP\""
|
||||
echo "/bin/mv \"$APP\" \"$BACKUP\" || exit 1"
|
||||
echo "/bin/mv \"$NEW_APP\" \"$APP\" || { /bin/mv \"$BACKUP\" \"$APP\"; exit 1; }"
|
||||
echo "/bin/rm -rf \"$BACKUP\""
|
||||
echo "APP=\"$APP\""
|
||||
echo "NEW_APP=\"$NEW_APP\""
|
||||
echo "BACKUP=\"$BACKUP\""
|
||||
echo 'if [ ! -d "$APP" ] && [ -d "$BACKUP" ]; then /bin/mv "$BACKUP" "$APP"; fi'
|
||||
echo 'if [ ! -d "$APP" ] && [ -d "$NEW_APP" ]; then'
|
||||
echo ' /bin/mv "$NEW_APP" "$APP" || exit 1'
|
||||
echo ' exit 0'
|
||||
echo 'fi'
|
||||
echo 'if [ ! -d "$APP" ]; then exit 1; fi'
|
||||
echo '/bin/rm -rf "$BACKUP"'
|
||||
echo '/bin/mv "$APP" "$BACKUP" || exit 1'
|
||||
echo '/bin/mv "$NEW_APP" "$APP" || { /bin/mv "$BACKUP" "$APP" 2>/dev/null; exit 1; }'
|
||||
echo '/bin/rm -rf "$BACKUP"'
|
||||
echo 'exit 0'
|
||||
} > "$ROOT_SH"
|
||||
/bin/chmod +x "$ROOT_SH"
|
||||
if ! /bin/chmod +x "$ROOT_SH"; then
|
||||
fallback_reveal "chmod ROOT_SH failed"
|
||||
exit 1
|
||||
fi
|
||||
if ! /usr/bin/osascript -e "do shell script \"/bin/sh '$ROOT_SH'\" with administrator privileges"; then
|
||||
echo "[helper] osascript failed/canceled -> 回退访达定位"
|
||||
/usr/bin/open -R "$NEW_APP"
|
||||
fallback_reveal "osascript failed or canceled"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. 清 quarantine(已公证+staple,防御性)+ open 重启新版
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null
|
||||
/usr/bin/open "$APP"
|
||||
echo "[helper] relaunched"
|
||||
# 5. 终检:/Applications 里必须有可用的 app 才继续,否则宁可停手也不再往下动(不清 quarantine、不 open)
|
||||
if [ ! -d "$APP" ]; then
|
||||
fallback_reveal "final check: app still missing after all recovery attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. 清理
|
||||
# 6. 清 quarantine(已公证+staple,防御性)+ open 重启新版;open 失败(Gatekeeper/TCC 拦截)记日志,尽力而为
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null
|
||||
if /usr/bin/open "$APP"; then
|
||||
echo "[helper] relaunched"
|
||||
else
|
||||
echo "[helper] open \"$APP\" failed, app updated on disk but not launched (Gatekeeper/TCC?); user needs to open manually"
|
||||
fi
|
||||
|
||||
# 7. 清理(仅在换装成功、$APP 确认可用后才清;失败路径保留现场供排查)
|
||||
/bin/rm -f "$ZIP" 2>/dev/null
|
||||
/bin/rm -rf "$STAGING/new" 2>/dev/null
|
||||
exit 0
|
||||
|
||||
@@ -245,9 +245,16 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
]);
|
||||
}
|
||||
|
||||
// switchMethod 内部已把网络/超时/未知异常兜到 failed 相位(与 start/resume 对齐,
|
||||
// 会经 build() 的 phase 分支自动切到 _failed() 呈现)——这里的 try/catch 是最后一道
|
||||
// 防线,防止任何逃逸异常变成无提示的 unhandled rejection(照抄 _openRedirectUrl 的写法)。
|
||||
Future<void> _pickAndSwitch(BuildContext context, WidgetRef ref, PaymentFlowState s) async {
|
||||
final other = s.method == 'crypto' ? 'nezha' : 'crypto';
|
||||
await ref.read(paymentFlowProvider.notifier).switchMethod(other);
|
||||
try {
|
||||
await ref.read(paymentFlowProvider.notifier).switchMethod(other);
|
||||
} catch (_) {
|
||||
if (context.mounted) showPangolinToast(context, t.orderCreateFailed);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _succeeded(BuildContext context, PaymentFlowState s) {
|
||||
|
||||
@@ -169,6 +169,15 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn);
|
||||
} catch (_) {
|
||||
// 网络/超时等非 Auth 异常也要落 failed —— 与 start()/resume() 同一形状,否则
|
||||
// 穿出到无 try/catch 的 _pickAndSwitch,UI 卡在 awaitingPayment 无提示。
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(
|
||||
phase: PaymentPhase.failed,
|
||||
errorZh: '下单失败,请检查网络后重试',
|
||||
errorEn: 'Failed to create order, please check your network and retry',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,20 @@ void main() {
|
||||
expect(c.read(paymentFlowProvider).order?.orderNo, 'pay2');
|
||||
});
|
||||
|
||||
test('switchMethod 遇非 Auth 异常(网络/超时)→ 落 failed 兜底,不卡 awaitingPayment', () async {
|
||||
final api = _FakePaymentApi();
|
||||
final c = _container(api);
|
||||
final ctl = c.read(paymentFlowProvider.notifier);
|
||||
await ctl.start(item, 'crypto');
|
||||
api.retryError = Exception('network timeout');
|
||||
await ctl.switchMethod('alipay');
|
||||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.failed);
|
||||
expect(c.read(paymentFlowProvider).errorZh, isNotNull);
|
||||
expect(c.read(paymentFlowProvider).errorZh, isNotEmpty);
|
||||
expect(c.read(paymentFlowProvider).errorEn, isNotNull);
|
||||
expect(c.read(paymentFlowProvider).errorEn, isNotEmpty);
|
||||
});
|
||||
|
||||
test('resume() 成功 → retry 恢复原单会话(不新下单),phase=awaitingPayment', () async {
|
||||
final api = _FakePaymentApi();
|
||||
final c = _container(api);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package nodes_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/config"
|
||||
"github.com/wangjia/pangolin/server/internal/nodes"
|
||||
"github.com/wangjia/pangolin/server/internal/store"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// C1 · EntitlementForUser is the *other* consumer of migration 000025's
|
||||
// users.max_devices_override — the per-connection backstop in
|
||||
// internal/httpapi/nodes.go (DEVICE_LIMIT_EXCEEDED). Before this fix it never
|
||||
// read the override, so an operator-granted override (e.g. 6) would pass the
|
||||
// login gate (internal/devices.ResolvePlan reads it) but still get the
|
||||
// account's *next VPN connection* rejected under the un-overridden plan cap.
|
||||
//
|
||||
// No container needed — modernc.org/sqlite is pure Go — so these run in
|
||||
// normal CI alongside run_sqlite_test.sh's data-layer tests, mirroring
|
||||
// internal/devices/device_limit_override_test.go.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func openEntitlementTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
cfg := &config.Config{Driver: "sqlite", DSN: ":memory:"}
|
||||
db, err := store.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := store.MigrateUp(db, "sqlite"); err != nil {
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// insertEntitlementTestUser inserts an active user with an optional
|
||||
// max_devices_override (NULL when override.Valid is false).
|
||||
func insertEntitlementTestUser(t *testing.T, db *sql.DB, email string, override sql.NullInt64) int64 {
|
||||
t.Helper()
|
||||
var (
|
||||
res sql.Result
|
||||
err error
|
||||
)
|
||||
if override.Valid {
|
||||
res, err = db.Exec(
|
||||
`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status, max_devices_override)
|
||||
VALUES (?, ?, 'x', ?, 'active', ?)`,
|
||||
"uuid-"+email, email, "dp-"+email, override.Int64)
|
||||
} else {
|
||||
res, err = db.Exec(
|
||||
`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status)
|
||||
VALUES (?, ?, 'x', ?, 'active')`,
|
||||
"uuid-"+email, email, "dp-"+email)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("insert user %s: %v", email, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("last insert id: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// giveEntitlementTestSubscription inserts an active subscription for userID
|
||||
// on the given seeded plan code (free/pro/team, from migration 000007).
|
||||
func giveEntitlementTestSubscription(t *testing.T, db *sql.DB, userID int64, planCode, source string, expiresAt time.Time) {
|
||||
t.Helper()
|
||||
var planID int64
|
||||
if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, planCode).Scan(&planID); err != nil {
|
||||
t.Fatalf("plan lookup %s: %v", planCode, err)
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, ?)`,
|
||||
userID, planID, expiresAt.UTC(), source); err != nil {
|
||||
t.Fatalf("give subscription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntitlementForUser_FreeUserNoOverride: a free user with no override set
|
||||
// gets the plan's hard-coded free default (1) — unchanged baseline behavior.
|
||||
func TestEntitlementForUser_FreeUserNoOverride(t *testing.T) {
|
||||
db := openEntitlementTestDB(t)
|
||||
st := nodes.NewSQLNodeStore(db)
|
||||
userID := insertEntitlementTestUser(t, db, "free-none@example.com", sql.NullInt64{})
|
||||
|
||||
ent, err := st.EntitlementForUser(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("EntitlementForUser: %v", err)
|
||||
}
|
||||
if ent == nil {
|
||||
t.Fatal("ent = nil, want entitlement")
|
||||
}
|
||||
if ent.PlanCode != "free" {
|
||||
t.Errorf("PlanCode = %q, want free", ent.PlanCode)
|
||||
}
|
||||
if ent.MaxDevices != 1 {
|
||||
t.Errorf("MaxDevices = %d, want 1 (free plan default, no override)", ent.MaxDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntitlementForUser_FreeUserWithOverride: same free user, override=6 —
|
||||
// the free-fallback branch (no active subscription row) must be overridden
|
||||
// too, otherwise the connect backstop rejects the 2nd..6th device even
|
||||
// though the login gate (devices.ResolvePlan) already allows them.
|
||||
func TestEntitlementForUser_FreeUserWithOverride(t *testing.T) {
|
||||
db := openEntitlementTestDB(t)
|
||||
st := nodes.NewSQLNodeStore(db)
|
||||
userID := insertEntitlementTestUser(t, db, "free-override@example.com", sql.NullInt64{Int64: 6, Valid: true})
|
||||
|
||||
ent, err := st.EntitlementForUser(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("EntitlementForUser: %v", err)
|
||||
}
|
||||
if ent.PlanCode != "free" {
|
||||
t.Errorf("PlanCode = %q, want free (override must not change plan identity)", ent.PlanCode)
|
||||
}
|
||||
if ent.MaxDevices != 6 {
|
||||
t.Errorf("MaxDevices = %d, want 6 (override) — connect backstop must agree with login gate", ent.MaxDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntitlementForUser_ProSubscriptionOverrideWins: a pro-subscription user
|
||||
// (plan default max_devices=3, migration 000019) with override=2 — the
|
||||
// subscription branch must also be overridden, even though the override is
|
||||
// *smaller* than the plan's own cap (operators can tighten a specific
|
||||
// account too, per internal/devices/store.go:305-306's documented semantics).
|
||||
func TestEntitlementForUser_ProSubscriptionOverrideWins(t *testing.T) {
|
||||
db := openEntitlementTestDB(t)
|
||||
st := nodes.NewSQLNodeStore(db)
|
||||
userID := insertEntitlementTestUser(t, db, "pro-override@example.com", sql.NullInt64{Int64: 2, Valid: true})
|
||||
giveEntitlementTestSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(24*time.Hour))
|
||||
|
||||
ent, err := st.EntitlementForUser(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("EntitlementForUser: %v", err)
|
||||
}
|
||||
if ent.PlanCode != "pro" {
|
||||
t.Errorf("PlanCode = %q, want pro", ent.PlanCode)
|
||||
}
|
||||
if ent.MaxDevices != 2 {
|
||||
t.Errorf("MaxDevices = %d, want 2 (override wins over pro plan's own cap of 3)", ent.MaxDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntitlementForUser_NullOrZeroOverrideDoesNotApply: NULL and 0 both mean
|
||||
// "no override" — the plan-derived cap must stand (free=1 here).
|
||||
func TestEntitlementForUser_NullOrZeroOverrideDoesNotApply(t *testing.T) {
|
||||
db := openEntitlementTestDB(t)
|
||||
st := nodes.NewSQLNodeStore(db)
|
||||
|
||||
nullUser := insertEntitlementTestUser(t, db, "null-override@example.com", sql.NullInt64{})
|
||||
zeroUser := insertEntitlementTestUser(t, db, "zero-override@example.com", sql.NullInt64{Int64: 0, Valid: true})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
userID int64
|
||||
}{
|
||||
{"NULL", nullUser},
|
||||
{"zero", zeroUser},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ent, err := st.EntitlementForUser(context.Background(), tc.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("EntitlementForUser: %v", err)
|
||||
}
|
||||
if ent.MaxDevices != 1 {
|
||||
t.Errorf("MaxDevices = %d, want 1 (no override should apply)", ent.MaxDevices)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -227,18 +227,37 @@ func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*E
|
||||
err := s.db.QueryRowContext(ctx, q, userID, time.Now().UTC()).Scan(
|
||||
&e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.DailyMB, &e.MaxDevices, &e.ExpiresAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
// No active subscription → free plan defaults (mirrors the free plan seed).
|
||||
e.PlanCode = "free"
|
||||
e.AdGate = true
|
||||
e.DailyMinutes = sql.NullInt64{Valid: true, Int64: 10}
|
||||
e.DailyMB = sql.NullInt64{Valid: true, Int64: 500}
|
||||
e.MaxDevices = 1
|
||||
return e, nil
|
||||
}
|
||||
if err != nil {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: plan: %w", err)
|
||||
}
|
||||
|
||||
// Per-user device-cap override (migration 000025, users.max_devices_override):
|
||||
// applies uniformly to BOTH branches above (subscription hit + free
|
||||
// fallback) — mirrors internal/devices/service.go's ResolvePlan funnel, so
|
||||
// the per-connection backstop (internal/httpapi/nodes.go's
|
||||
// DEVICE_LIMIT_EXCEEDED check, which reads Entitlement.MaxDevices) agrees
|
||||
// with the login gate / /v1/me / devices.CheckDeviceLimit. NULL or <=0
|
||||
// means "no override" (same semantics as devices/store.go's
|
||||
// GetMaxDevicesOverride); a positive value always wins, even if smaller
|
||||
// than the plan's own cap.
|
||||
var override sql.NullInt64
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT max_devices_override FROM users WHERE id = ?`, userID,
|
||||
).Scan(&override); err != nil && err != sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: override: %w", err)
|
||||
}
|
||||
if override.Valid && override.Int64 > 0 {
|
||||
e.MaxDevices = int(override.Int64)
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,42 @@ func (s *Store) HasPaidPurchase(ctx context.Context, userID int64, sku string) (
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// HasPaidPurchaseExcludingTx 是 HasPaidPurchase 的事务内(锁行后)复查版本,供
|
||||
// webhook.go settle 在开通前对 Promo SKU 再查一次——CreateOrder 那次下单时的
|
||||
// 检查是裸 SELECT 无锁,并发/多挂起单可绕过(TOCTOU)。excludeID 排除本单自己
|
||||
// (本单尚未 MarkPaidTx,通常不会自匹配,但显式排除更稳妥、也便于未来复用)。
|
||||
func (s *Store) HasPaidPurchaseExcludingTx(ctx context.Context, tx *sql.Tx, userID int64, sku string, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM pay_purchases WHERE user_id = ? AND sku = ? AND status = 'paid' AND id <> ?`,
|
||||
userID, sku, excludeID).Scan(&n)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("pay.Store.HasPaidPurchaseExcludingTx: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// MarkDuplicatePromoTx 收口一笔在 TOCTOU 竞争中"输"掉的 Promo SKU 重复单——
|
||||
// 另一笔同 user+SKU 的订单已先一步 settle 为 paid(见 webhook.go settle 的
|
||||
// promo 复查)。刻意标记为 'canceled' 而非 'paid':(user_id, sku) WHERE
|
||||
// status='paid' 是部分唯一索引(migration 000027,仅 sqlite)本就不允许同一
|
||||
// user+promo-SKU 出现第二条 paid 行,这里若也写 paid 会在 sqlite 上直接撞
|
||||
// 约束报错、把整个 webhook 打成 500 引发 pay 无限重投——与"吞掉重复单,不
|
||||
// 再重投"的目标相反。仍落一次结算回执字段(amount/currency/channel/paid_at)
|
||||
// 供人工核对"钱是否真收到过、为何没有二次开通",不同于用户主动取消未付
|
||||
// 单的语义(MarkCanceled 的原生用途),但复用同一 status 取值。
|
||||
func (s *Store) MarkDuplicatePromoTx(ctx context.Context, tx *sql.Tx, id int64, amountMinor int64, currency, channel string, paidAt time.Time) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE pay_purchases SET status = 'canceled', amount_minor = ?, currency = ?,
|
||||
channel = ?, paid_at = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
amountMinor, currency, channel, paidAt, time.Now().UTC(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pay.Store.MarkDuplicatePromoTx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubscriptionExpiry 查开通行的到期时间(查单响应回带给客户端)。
|
||||
func (s *Store) SubscriptionExpiry(ctx context.Context, subID int64) (time.Time, error) {
|
||||
var exp time.Time
|
||||
|
||||
@@ -178,15 +178,38 @@ func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error {
|
||||
purchaseID, userID = row.ID, row.UserID
|
||||
}
|
||||
|
||||
paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt)
|
||||
if perr != nil {
|
||||
paidAt = h.now().UTC()
|
||||
}
|
||||
|
||||
// C2 安全修复(promo 限购 TOCTOU):CreateOrder 下单时的 HasPaidPurchase 只在
|
||||
// 下单一刻查、裸 SELECT 无锁——并发/多笔挂起单可绕过"每账号限购一次"。这里
|
||||
// 在锁行、开通前对 Promo SKU 再复查一次:命中说明另一笔同 user+SKU 的订单
|
||||
// 已抢先 settle 为 paid,本单跳过发放(不二次 +N 天),仍需 ACK 200,否则 pay
|
||||
// 会把 500 当失败无限重投。sqlite 侧另有 migration 000027 的部分唯一索引
|
||||
// (user_id, sku) WHERE status='paid' 兜底;mysql 不支持部分索引,本检查是
|
||||
// mysql 侧唯一防线(见该迁移文件注释)。
|
||||
if item.Promo {
|
||||
dup, err := h.store.HasPaidPurchaseExcludingTx(ctx, tx, userID, ev.ProductBizCode, purchaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dup {
|
||||
slog.Warn("pay webhook: promo 限购 TOCTOU 命中,跳过发放并吞掉重复单",
|
||||
"order_no", ev.OutTradeNo, "user_id", userID, "sku", ev.ProductBizCode)
|
||||
if err := h.store.MarkDuplicatePromoTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, paidAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
}
|
||||
|
||||
subID, _, err := h.granter.GrantPaidSubscriptionTx(ctx, tx, userID,
|
||||
codes.PlanCode(item.Plan), item.Days, "pay:"+ev.OutTradeNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt)
|
||||
if perr != nil {
|
||||
paidAt = h.now().UTC()
|
||||
}
|
||||
if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// C2 · promo 限购 TOCTOU: CreateOrder's HasPaidPurchase check is a bare,
|
||||
// unlocked SELECT at order-creation time — concurrent/multi-pending orders
|
||||
// for the same promo SKU can both reach 'created' before either settles.
|
||||
// webhook.go's settle must re-check inside the locked transaction, before
|
||||
// granting, and swallow the duplicate (skip the grant, still ACK so pay
|
||||
// doesn't retry forever) rather than double-granting +31 days per order.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// seedPromoDuplicateOrders inserts two 'created' orders for the SAME user +
|
||||
// promo SKU (pro_month_promo) — simulating the TOCTOU window where both
|
||||
// orders were created before either was paid.
|
||||
func seedPromoDuplicateOrders(t *testing.T, st *Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := st.Insert(ctx, 1, "uuid-1", "pro_month_promo", "pay-promo-1", "alipay", 600, "CNY"); err != nil {
|
||||
t.Fatalf("insert promo order 1: %v", err)
|
||||
}
|
||||
if err := st.Insert(ctx, 1, "uuid-1", "pro_month_promo", "pay-promo-2", "wxpay", 600, "CNY"); err != nil {
|
||||
t.Fatalf("insert promo order 2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_PromoDuplicateSettleSkipsGrantButAcks(t *testing.T) {
|
||||
h, db, st := newWebhookRig(t)
|
||||
seedPromoDuplicateOrders(t, st)
|
||||
|
||||
// First promo order settles normally: paid + granted.
|
||||
w1 := deliver(t, h, succeededPayload("pay-promo-1", "pro_month_promo"))
|
||||
if w1.Code != http.StatusOK || !strings.Contains(w1.Body.String(), "SUCCESS") {
|
||||
t.Fatalf("第一单应正常开通: %d %q", w1.Code, w1.Body.String())
|
||||
}
|
||||
var expiresAfterFirst time.Time
|
||||
if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expiresAfterFirst); err != nil {
|
||||
t.Fatalf("第一单未开通订阅: %v", err)
|
||||
}
|
||||
|
||||
// Second promo order for the SAME user+SKU settles (TOCTOU duplicate):
|
||||
// must still ACK 200 SUCCESS (else pay retries this webhook forever),
|
||||
// but must NOT grant a second +31 days.
|
||||
w2 := deliver(t, h, succeededPayload("pay-promo-2", "pro_month_promo"))
|
||||
if w2.Code != http.StatusOK || !strings.Contains(w2.Body.String(), "SUCCESS") {
|
||||
t.Fatalf("重复 promo 单应仍 ACK(否则 pay 会无限重投): %d %q", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("不得为重复 promo 单二次开通: subscriptions rows = %d, want 1", n)
|
||||
}
|
||||
var expiresAfterSecond time.Time
|
||||
if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expiresAfterSecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !expiresAfterFirst.Equal(expiresAfterSecond) {
|
||||
t.Errorf("到期被重复叠加: %v → %v(应保持不变,promo 限购一次)", expiresAfterFirst, expiresAfterSecond)
|
||||
}
|
||||
|
||||
// The duplicate order's own ledger row must not become a second 'paid'
|
||||
// row for the same (user_id, sku) — that's exactly what migration 000027's
|
||||
// partial unique index forbids on sqlite, and what the settle-side
|
||||
// re-check must avoid ever attempting.
|
||||
row2, err := st.GetForUser(context.Background(), 1, "pay-promo-2")
|
||||
if err != nil {
|
||||
t.Fatalf("重复单台账未找到: %v", err)
|
||||
}
|
||||
if row2.Status == "paid" {
|
||||
t.Errorf("重复 promo 单不应被标记为二条 paid(会撞 (user_id,sku) 唯一索引): status = %q", row2.Status)
|
||||
}
|
||||
if row2.SubID.Valid {
|
||||
t.Errorf("重复 promo 单不应挂 sub_id: %+v", row2.SubID)
|
||||
}
|
||||
|
||||
// Redelivery of the SAME duplicate webhook (new nonce, same out_trade_no)
|
||||
// must remain idempotent — no further side effects, still ACK.
|
||||
w3 := deliver(t, h, succeededPayload("pay-promo-2", "pro_month_promo"))
|
||||
if w3.Code != http.StatusOK || !strings.Contains(w3.Body.String(), "SUCCESS") {
|
||||
t.Fatalf("重复单再次重投应仍 ACK: %d %q", w3.Code, w3.Body.String())
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("重投重复单不得二次开通: subscriptions rows = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Defense-in-depth: migration 000027's sqlite partial unique index directly
|
||||
// forbids two 'paid' rows for the same (user_id, sku='pro_month_promo'),
|
||||
// independent of the application-layer settle check above. This guards
|
||||
// against any other write path (bug, manual SQL, future code) accidentally
|
||||
// double-marking a promo purchase 'paid'.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestPayPurchases_PromoPaidUniqueIndex_SQLite(t *testing.T) {
|
||||
db := openMigratedSQLite(t)
|
||||
seedUser(t, db, 1, "uuid-1")
|
||||
now := time.Now().UTC()
|
||||
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at)
|
||||
VALUES (1, 'uuid-1', 'pro_month_promo', 'ux-1', 'alipay', 'paid', 600, 'CNY', ?, ?)`,
|
||||
now, now); err != nil {
|
||||
t.Fatalf("first paid promo row should insert cleanly: %v", err)
|
||||
}
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at)
|
||||
VALUES (1, 'uuid-1', 'pro_month_promo', 'ux-2', 'wxpay', 'paid', 600, 'CNY', ?, ?)`,
|
||||
now, now)
|
||||
if err == nil {
|
||||
t.Fatal("第二条同 user+promo-sku 的 paid 行应被部分唯一索引拒绝,却插入成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UNIQUE") {
|
||||
t.Errorf("err = %v, want a UNIQUE constraint violation", err)
|
||||
}
|
||||
|
||||
// A non-promo SKU (or a 'created'/'canceled' status row) must be
|
||||
// unaffected by the partial index — sanity check it isn't over-broad.
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at)
|
||||
VALUES (1, 'uuid-1', 'pro_month', 'ux-3', 'alipay', 'paid', 4990000, 'USDT', ?, ?)`,
|
||||
now, now); err != nil {
|
||||
t.Errorf("非 promo SKU 的 paid 行不应受此索引影响: %v", err)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -104,11 +104,15 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) {
|
||||
// the reviewer's repro hit: the lib's `codes` table collides with the
|
||||
// name 000022's down script renames legacy_codes back to.
|
||||
m := newSQLiteStepper(t, db)
|
||||
// 000026 (notices), 000025 (user_device_limit_override), 000024
|
||||
// (invite_rewards) and 000023 (pay_purchases/source-enum) now sit on top
|
||||
// of 000022 (codes_lib_legacy_rename) and are unrelated to this
|
||||
// collision — step them back down first so we land exactly on the
|
||||
// 000022 boundary the test targets.
|
||||
// 000027 (pay_promo_paid_unique), 000026 (notices), 000025
|
||||
// (user_device_limit_override), 000024 (invite_rewards) and 000023
|
||||
// (pay_purchases/source-enum) now sit on top of 000022
|
||||
// (codes_lib_legacy_rename) and are unrelated to this collision — step
|
||||
// them back down first so we land exactly on the 000022 boundary the
|
||||
// test targets.
|
||||
if err := m.Steps(-1); err != nil {
|
||||
t.Fatalf("step 000027 down: %v", err)
|
||||
}
|
||||
if err := m.Steps(-1); err != nil {
|
||||
t.Fatalf("step 000026 down: %v", err)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
||||
if dirty {
|
||||
t.Fatalf("schema dirty after MigrateUp")
|
||||
}
|
||||
if v != 26 {
|
||||
t.Errorf("version = %d, want 26", v)
|
||||
if v != 27 {
|
||||
t.Errorf("version = %d, want 27", v)
|
||||
}
|
||||
|
||||
// 2. Core tables exist.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- no-op(与 up 对称,无 DDL 可回滚)
|
||||
@@ -0,0 +1,7 @@
|
||||
-- no-op(MySQL 8):MySQL 不支持部分唯一索引(WHERE 子句),无法照搬 sqlite 版
|
||||
-- 的 (user_id, sku) WHERE status='paid' AND sku='pro_month_promo' 约束——建全表
|
||||
-- 唯一索引 (user_id, sku, status) 会连带拦掉 created/canceled 状态下的正常
|
||||
-- 复购/重试流程,不可行。mysql 侧的 promo 限购 TOCTOU 防线仅落在应用层:
|
||||
-- webhook.go settle 在锁行开通前对 item.Promo 的 SKU 复查一次 HasPaidPurchase,
|
||||
-- 命中则跳过发放、吞掉重复单(详见该函数注释)。此文件仅占位对齐 sqlite 的
|
||||
-- 000027 编号,不执行任何 DDL。
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS ux_pay_promo_paid;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- C2 安全修复(promo 限购 TOCTOU):CreateOrder 下单时的 HasPaidPurchase 只是裸
|
||||
-- SELECT(无锁),并发/多挂起单可绕过「每账号限购一次」。这里加部分唯一索引兜底:
|
||||
-- 同一 user_id 的 pro_month_promo 至多一条 status='paid'。sqlite 支持部分索引
|
||||
-- (WHERE 子句),精确限定 sku='pro_month_promo'(目前唯一的 Promo=true 档位),
|
||||
-- 不影响其余 SKU 正常复购。webhook.go settle 侧另有应用层复查作为第一道防线
|
||||
-- (mysql 侧 000027 是 no-op,不支持部分索引——见 mysql 版本注释)。
|
||||
CREATE UNIQUE INDEX ux_pay_promo_paid
|
||||
ON pay_purchases (user_id, sku)
|
||||
WHERE status = 'paid' AND sku = 'pro_month_promo';
|
||||
@@ -18,7 +18,7 @@
|
||||
* ——与主页行为一致,用户可用 Header 的语言下拉自行切换(切换会带上当前的邀请码路径,见
|
||||
* Header.jsx 的 `pagePath` 机制)。
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Copy, Check, Download, UserPlus, Gift, ArrowRight } from 'lucide-react';
|
||||
import { isLoggedIn } from '../lib/authState';
|
||||
import { INVITE_CODE_RE } from '../lib/inviteCode';
|
||||
@@ -163,12 +163,17 @@ export default function InviteCard({ lang = 'en' }) {
|
||||
const [code, setCode] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
// onCopy 里 1.5s 后把 copied 复位的 setTimeout 存 ref,卸载时 clear——
|
||||
// 否则用户复制后立刻离开页面,定时器仍会在已卸载组件上触发 setState。
|
||||
const copyTimeoutRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCode(extractCode(window.location.pathname));
|
||||
setLoggedIn(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimeout(copyTimeoutRef.current), []);
|
||||
|
||||
const t = COPY[lang] || COPY.en;
|
||||
const dlHref = useMemo(() => `${langPath(lang)}#download`, [lang]);
|
||||
const registerHref = useMemo(
|
||||
@@ -181,7 +186,8 @@ export default function InviteCard({ lang = 'en' }) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// 剪贴板权限失败时静默,用户仍可长按/选中手动复制。
|
||||
}
|
||||
|
||||
@@ -311,6 +311,9 @@ export default function RegisterCard({ lang = 'en' }) {
|
||||
const [redirectIn, setRedirectIn] = useState(REDIRECT_SECS);
|
||||
const redirectRef = useRef(null);
|
||||
const codeInputRef = useRef(null);
|
||||
// 卸载守卫:onSendCode/onRegister 里 await callApi 期间组件若已卸载(用户导航离开),
|
||||
// 之后的 setState 直接跳过——避免 unmounted 组件 setState 的告警/竞态。
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const t = COPY[lang] || COPY.en;
|
||||
// 后端错误字段只有 message_zh/message_en 两语;非中文一律取英文兜底(见组件顶部注释)。
|
||||
@@ -334,6 +337,7 @@ export default function RegisterCard({ lang = 'en' }) {
|
||||
|
||||
useEffect(() => () => clearInterval(cooldownRef.current), []);
|
||||
useEffect(() => () => clearInterval(redirectRef.current), []);
|
||||
useEffect(() => () => { mountedRef.current = false; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (codeSent && codeInputRef.current) codeInputRef.current.focus();
|
||||
@@ -381,6 +385,7 @@ export default function RegisterCard({ lang = 'en' }) {
|
||||
}
|
||||
setSendingCode(true);
|
||||
const r = await callApi('/v1/auth/code', { email: trimmed });
|
||||
if (!mountedRef.current) return;
|
||||
setSendingCode(false);
|
||||
if (!r.ok) {
|
||||
setCodeErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr);
|
||||
@@ -403,6 +408,7 @@ export default function RegisterCard({ lang = 'en' }) {
|
||||
const body = { email: trimmedEmail, code, password };
|
||||
if (invite) body.invite_code = invite;
|
||||
const r = await callApi('/v1/auth/register', body);
|
||||
if (!mountedRef.current) return;
|
||||
setSubmitting(false);
|
||||
if (!r.ok) {
|
||||
setRegErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr);
|
||||
|
||||
Reference in New Issue
Block a user