b461a476a2
四端共享 Dart 实现,配合 #21 后端账户级卡控: - me.dart: 加 quota_cap_min(当日总额度 = daily + 看广告 bonus,进度条分母)。 - quota_provider: total 取 quotaCapMin;isExhausted;markExhausted(倒计时归零本地置耗尽 + 登录态拉 me 校准);watchAd() async 调 /ads/unlock(占位 ad_token=uuid)→ 刷新 me,返回 granted。 - connection_provider: 连接时锁定 _freeRemainingSec(会员 null 不倒计时);复用 elapsed 计时器 每 tick 算 freeCountdown,归零 → _onFreeQuotaExhausted(主动切断不报节点异常 + markExhausted); 倒计时用墙上时钟(后台漏跳回前台补上、准时切);连接遇后端 QUOTA_EXHAUSTED 兜底置耗尽。 - connect_button: enabled/onDisabledTap —— 额度耗尽 off 态灰化(锁图标),点击弹加时。 - quota_card: 三态(连接倒计时 mm:ss / 未连接剩余分钟 / 耗尽今日已用完);移动「看广告加时」、桌面「升级会员」。 - ad_reward_dialog(新): 移动端占位广告(播放中→3s→加时→奖励);桌面版硬 10 分钟提示去移动端/升级。 - l10n(zh/en): 倒计时/已用完/看广告加时/占位广告/桌面升级 双语。 - 测试: quota isExhausted/markExhausted;连接倒计时归零自动切断+置耗尽(注入时钟); 额度卡三态 widget;/me 契约含 quota_cap_min;golden 更新(Linux 权威基线 quota_low/exhausted)。 - 文档: docs/free-quota-ad.html + 登记 docs/index.html。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
128 lines
10 KiB
HTML
128 lines
10 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>免费版 10 分钟卡控 + 累加式看广告加时(设计 · #21)</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||
p{margin:10px 0}
|
||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||
.card.root{border-left:3px solid var(--accent)}
|
||
.card h3{margin-top:0}
|
||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||
td code{font-size:.85em}
|
||
ul,ol{padding-left:22px;margin:10px 0}
|
||
li{margin:5px 0}
|
||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||
.small{color:var(--fg2);font-size:13px}
|
||
a{color:var(--accent2)}
|
||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||
b{color:#fff}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<a class="back" href="index.html">← 返回文档索引</a>
|
||
|
||
<h1>免费版 10 分钟卡控 + 累加式看广告加时</h1>
|
||
<p class="sub">设计 · todo #21 · <span class="tag info">后端 + 前端 + 数据库</span> · 额度全账户共享</p>
|
||
|
||
<div class="lead">
|
||
免费版此前<b>形同虚设</b>:连接页无倒计时、时间到不卡控、按钮永远可点。根因在服务端——
|
||
<code>ConnectNode</code> 免费门每次连接都发<b>固定 <code>daily_minutes×1min</code> TTL、从不扣减已用</b>,
|
||
重连即崭新 10 分钟,日额度从未真正强制。本次将其改为<b>账户级(全设备共享)真卡控</b> +
|
||
<b>累加式看广告加时</b>:连接期倒计时、到点自动切断、耗尽按钮灰化、点击弹广告、看完 +N 分钟。
|
||
</div>
|
||
|
||
<h2>目标与口径(已确认)</h2>
|
||
<ul>
|
||
<li><b>连接期倒计时</b>:连上后显示剩余 mm:ss,到 0 自动切断隧道。</li>
|
||
<li><b>耗尽卡控</b>:额度用完 → 连接按钮变灰不可点。</li>
|
||
<li><b>看广告加时(累加式)</b>:点灰按钮弹广告,看完 <b>+10 分钟</b>(可重复,每日封顶 <b>120 分钟</b>)。</li>
|
||
<li><b>占位广告 SDK</b>:先跑通假流程(dialog→“播放中”→奖励),服务端用放行式 <code>DevVerifier</code>(nonce 仍防重放),接真 AdMob 时替换。</li>
|
||
<li><b>桌面(Windows/macOS)</b>:免费 = <b>硬 10 分钟/天不可延</b>,无广告;到点→切断+灰按钮+提示“去移动端看广告或升级”。</li>
|
||
<li><b>额度全账户共享</b>:非每设备。服务端 <code>usage_daily</code> 本就按 <code>user_id</code> 聚合所有设备的 <code>minutes_used</code>,天然账户级;客户端倒计时仅本地近似,权威始终以 <code>quota_today_min</code> 为准。</li>
|
||
</ul>
|
||
|
||
<h2>数据模型</h2>
|
||
<div class="card root">
|
||
<h3>migration 000020_ad_bonus_minutes(sqlite + mysql)</h3>
|
||
<pre>ALTER TABLE usage_daily ADD COLUMN ad_bonus_minutes INT NOT NULL DEFAULT 0;</pre>
|
||
<p>当日免费额度 = <code>plans.daily_minutes</code>(free=10)+ <code>usage_daily.ad_bonus_minutes</code>(看广告累加);
|
||
剩余 = 额度 − <code>minutes_used</code>。历史列 <code>ad_unlocked_at</code>(布尔式当日解锁)保留但不再用于卡控。</p>
|
||
</div>
|
||
|
||
<h2>后端</h2>
|
||
<table>
|
||
<tr><th>层</th><th>改动</th></tr>
|
||
<tr><td><code>usage/store.go</code></td><td><code>AddAdBonusMinutes(uid,day,add,ceiling)</code>:事务内 <code>LockForUpdate</code> 读旧值 → 累加封顶 → upsert,返回<b>新总额 + 本次实际加时</b>。<code>DailyUsage</code>/<code>GetDay</code>/<code>GetUsageRange</code> 补读 <code>ad_bonus_minutes</code>。<code>MarkAdUnlocked</code> 标 deprecated。</td></tr>
|
||
<tr><td><code>usage/service.go</code></td><td>常量 <code>adBonusPerAd=10</code> / <code>adDailyBonusCeiling=120</code>。<code>TodaySummary</code> 加 <code>MinutesCap</code>(=daily+bonus)/ <code>AdBonusMinutes</code>;<code>remaining=cap−used</code>。<code>UnlockAd</code> 改累加:verify+nonce 后 <code>+adBonusPerAd</code> 封顶,返回 <code>(granted, remaining)</code>。</td></tr>
|
||
<tr><td><code>usage/ads.go</code> + <code>main.go</code></td><td>放行式 <code>DevVerifier</code>(<code>Verify</code> 恒 nil)。<code>main.go</code> 按 <code>ADS_DEV_MODE</code>/默认装配(替换现在的 <code>nil</code>——否则 <code>UnlockAd</code> 直接 <code>ErrInternal</code>,占位流程走不通)。nonce 防重放仍生效。</td></tr>
|
||
<tr><td><code>httpapi/nodes.go</code> ConnectNode</td><td>新增 <code>nodes.store.AccountDayMinutes(uid,day)→(used,bonus)</code>。免费门:<code>allowance=daily+bonus</code>,<code>remaining=allowance−used</code>;<code>remaining≤0</code>→拒 <code>QUOTA_EXHAUSTED</code>;否则 <b><code>TTL=remaining×1min</code></b>(凭证到点硬切断兜底,防绕过客户端)。</td></tr>
|
||
<tr><td><code>httpapi/account.go</code> /me</td><td>补读 <code>ad_bonus_minutes</code>;<code>quota_today_min</code> 分母改 <code>daily+bonus</code>;新增 <code>quota_cap_min</code>(=allowance,客户端进度条分母)。</td></tr>
|
||
<tr><td><code>usage/handler.go</code></td><td><code>POST /v1/ads/unlock</code>:204 → <b>200</b> 返回 <code>{granted_minutes, minutes_remaining}</code>。</td></tr>
|
||
</table>
|
||
|
||
<h2>前端(四端共享 Dart)</h2>
|
||
<table>
|
||
<tr><th>层</th><th>改动</th></tr>
|
||
<tr><td><code>models/me.dart</code></td><td>加 <code>quotaCapMin</code>(当日总额度)。</td></tr>
|
||
<tr><td><code>state/quota_provider.dart</code></td><td><code>total=me.quotaCapMin</code>;<code>isExhausted</code>;<code>markExhausted()</code>(倒计时归零本地置耗尽 + 登录态拉 me 校准);<code>watchAd()</code> async 调 <code>/ads/unlock</code>(占位 ad_token=uuid)→ 刷新 me,返回 granted。</td></tr>
|
||
<tr><td><code>state/connection_provider.dart</code></td><td>连接时锁定 <code>_freeRemainingSec</code>(会员为 null 不倒计时)。复用 elapsed 计时器每 tick 算 <code>countdown=cap−elapsed</code>,写入 <code>ConnectionState.freeCountdown</code>;<b>归零→自动切断</b>(<code>_onFreeQuotaExhausted</code>:主动断开不报节点异常 + <code>markExhausted</code>)。倒计时用墙上时钟,后台漏跳回前台补上、准时切。连接遇后端 <code>QUOTA_EXHAUSTED</code> 兜底置耗尽。</td></tr>
|
||
<tr><td><code>widgets/connect_button.dart</code></td><td>加 <code>enabled</code>/<code>onDisabledTap</code>:off 态额度耗尽 → 灰化(锁图标),点击走加时流程。</td></tr>
|
||
<tr><td><code>widgets/quota_card.dart</code></td><td>三态:连接中显示倒计时 mm:ss + 进度收缩;未连接有余额显示剩余分钟;耗尽显示“今日已用完”。移动端「看广告加时」/ 桌面「升级会员」。</td></tr>
|
||
<tr><td><code>widgets/ad_reward_dialog.dart</code>(新)</td><td>移动端占位广告:“广告播放中…”→3s→调 <code>watchAd</code>→显示“已加 N 分钟”自动关闭。桌面版:升级/移动端提示弹窗(无广告)。</td></tr>
|
||
<tr><td>l10n(zh/en)</td><td>倒计时/今日已用完/看广告加时/占位广告播放/奖励/桌面升级提示 双语文案。</td></tr>
|
||
</table>
|
||
|
||
<h2>时序</h2>
|
||
<div class="card">
|
||
<h3>移动端典型流</h3>
|
||
<pre>连接 → /me remaining=10 → 倒计时 10:00 …… 00:00
|
||
→ 客户端 _onFreeQuotaExhausted:切断隧道 + 按钮灰化 + markExhausted
|
||
点灰按钮 → 占位广告 dialog(3s)→ POST /ads/unlock(DevVerifier 放行 + nonce)
|
||
→ 服务端 ad_bonus_minutes += 10(封顶 120)→ 返回 granted=10, remaining=10
|
||
→ 刷新 /me(quota_cap_min=20, quota_today_min=10)→ 按钮恢复可连
|
||
再次连接 → ConnectNode remaining=allowance−used → TTL=remaining(服务端硬切断兜底)</pre>
|
||
</div>
|
||
<p class="small"><b>桌面</b>:同样倒计时 + 到点切断 + 灰按钮,但点击弹“去移动端看广告或升级会员”,<b>无加时路径</b>(硬 10 分钟/天)。</p>
|
||
|
||
<h2>验证</h2>
|
||
<ul>
|
||
<li>后端单测:<code>AddAdBonusMinutes</code> 累加+封顶(SQLite 实库);<code>TodaySummary</code> cap/remaining;<code>UnlockAd</code> 走 DevVerifier 加时;ConnectNode remaining≤0 拒 / TTL=remaining(集成测试)。</li>
|
||
<li>客户端:<code>flutter analyze</code> + <code>flutter test</code>(quota 倒计时/耗尽/加时;额度卡三态;/me 契约含 <code>quota_cap_min</code>)。</li>
|
||
<li>真机:免费连接→倒计时→到 0 自动断+灰按钮;点灰→移动弹占位广告→+10 分钟→恢复可连;桌面到点→灰+升级提示(无广告);同账户两设备共享同一剩余。</li>
|
||
</ul>
|
||
|
||
<h2>不在本轮</h2>
|
||
<ul>
|
||
<li>真 AdMob/激励视频 SDK 接入(<code>DevVerifier</code> 占位替换)。</li>
|
||
<li>广告频次风控细化(现仅每日封顶 <code>adDailyBonusCeiling</code>)。</li>
|
||
<li>客户端倒计时与服务端 <code>minutes_used</code> 聚合延迟的精确对账(以服务端 TTL 硬切断兜底)。</li>
|
||
</ul>
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|