feat(subscription): 对齐 SleepHeart USD 定价与 IAP product_id

默认周/月/年卡改为 $1/$3/$12,季卡 $8;同步迁移、客户端默认方案与 popadmin 配置页。
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-24 22:21:37 +08:00
parent 347dc191b7
commit 74a57ed25c
6 changed files with 224 additions and 26 deletions

View File

@ -3087,45 +3087,122 @@ const pages = {
},
async subPlans() {
// SleepHeart ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com
const PLAN_DEFAULTS = {
week: { title: '周卡', subtitle: '灵活体验,随时可停', price_display: '$1/周', price_cents: 100, period_days: 7, badge: '', sort_order: 1, product_id: 'health.wyrs365.com.vip.week' },
month: { title: '月卡', subtitle: '最受欢迎', price_display: '$3/月', price_cents: 300, period_days: 30, badge: '热门', sort_order: 2, product_id: 'health.wyrs365.com.vip.month' },
quarter: { title: '季卡', subtitle: '平均每月更省', price_display: '$8/季', price_cents: 800, period_days: 90, badge: '', sort_order: 3, product_id: 'health.wyrs365.com.vip.quarter' },
year: { title: '年卡', subtitle: '全年畅听会员曲库', price_display: '$12/年', price_cents: 1200, period_days: 365, badge: '超值', sort_order: 4, product_id: 'health.wyrs365.com.vip.year' }
};
let items = [];
try {
const data = await api('/spark/subscriptions/plans?lang=zh-CN');
const data = await api('/spark/subscriptions/plans');
items = (data && data.items) || [];
} catch (e) {
$('content').innerHTML = `<div class="note error">订阅配置 API:${esc(e.message)}</div>`;
return;
}
const fillForm = (p) => {
if (!p) return;
$('spPlan').value = p.plan_id || 'week';
$('spLang').value = p.lang || 'zh-CN';
$('spTitle').value = p.title || '';
$('spSub').value = p.subtitle || '';
$('spPrice').value = p.price_display || '';
$('spCents').value = p.price_cents != null ? p.price_cents : '';
$('spCurrency').value = p.currency || 'USD';
$('spDays').value = p.period_days != null ? p.period_days : 7;
$('spBadge').value = p.badge || '';
$('spProduct').value = p.product_id || '';
$('spSort').value = p.sort_order != null ? p.sort_order : 1;
$('spEnabled').checked = p.enabled !== false && p.enabled !== 0;
};
const applyPlanDefaults = () => {
const d = PLAN_DEFAULTS[$('spPlan').value] || PLAN_DEFAULTS.week;
if (!$('spTitle').value) $('spTitle').value = d.title;
if (!$('spSub').value) $('spSub').value = d.subtitle;
if (!$('spPrice').value) $('spPrice').value = d.price_display;
if (!$('spCents').value) $('spCents').value = d.price_cents;
if (!$('spDays').value) $('spDays').value = d.period_days;
if (!$('spBadge').value && d.badge) $('spBadge').value = d.badge;
if (!$('spProduct').value) $('spProduct').value = d.product_id;
if (!$('spSort').value) $('spSort').value = d.sort_order;
$('spCurrency').value = 'USD';
};
$('content').innerHTML = `
<div class="note">SleepHeart 订阅引导 · <code>/api/admin/spark/subscriptions/plans</code> · 客户端 <code>GET /v1/subscriptions/plans</code> · plan_id=week|month|quarter|year</div>
<div class="note">SleepHeart 订阅引导 · ASC App <code>6815533078</code> · Team <code>49S9UL5MN4</code> · Bundle <code>health.wyrs365.com</code><br>
Admin <code>/api/admin/spark/subscriptions/plans</code> · 客户端 <code>GET /v1/subscriptions/plans</code> · 默认 USD <b>$1/周 · $3/月 · $12/年</b>(季卡 $8 可保留)· IAP <code>health.wyrs365.com.vip.{week|month|quarter|year}</code></div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
<select id="spPlan"><option value="week">week</option><option value="month">month</option><option value="quarter">quarter</option><option value="year">year</option></select>
<select id="spLang"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
<input id="spTitle" placeholder="标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:120px">
<input id="spSub" placeholder="副标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">
<input id="spPrice" placeholder="展示价" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
<input id="spCents" type="number" placeholder="分" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
<input id="spPrice" placeholder="$1/周" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
<input id="spCents" type="number" placeholder="美分 100" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:100px">
<select id="spCurrency"><option value="USD" selected>USD</option><option value="CNY">CNY</option><option value="TWD">TWD</option></select>
<input id="spDays" type="number" placeholder="天数" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:80px">
<input id="spBadge" placeholder="角标" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:80px">
<input id="spProduct" placeholder="product_id" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">
<input id="spProduct" placeholder="health.wyrs365.com.vip.week" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:220px">
<input id="spSort" type="number" value="1" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:70px">
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="spEnabled" checked>启用</label>
<button type="button" id="spSave">保存套餐</button>
<button type="button" class="secondary" id="spFillDefaults">填入默认价</button>
<button type="button" class="secondary" id="spReload">刷新</button>
</div>
<table>
<thead><tr><th>plan</th><th>lang</th><th>标题</th><th>价格</th><th>天数</th><th>角标</th><th>product</th><th>启用</th></tr></thead>
<thead><tr><th>plan</th><th>lang</th><th>标题</th><th>价格</th><th>币种</th><th>天数</th><th>角标</th><th>product</th><th>启用</th></tr></thead>
<tbody>
${items.length ? items.map((p)=>`<tr><td>${esc(p.plan_id)}</td><td>${esc(p.lang)}</td><td>${esc(p.title)}<div class="note" style="margin:0">${esc(p.subtitle||'')}</div></td><td>${esc(p.price_display)} (${esc(p.price_cents)})</td><td>${esc(p.period_days)}</td><td>${esc(p.badge||'')}</td><td>${esc(p.product_id)}</td><td>${esc(p.enabled)}</td></tr>`).join('') : '<tr><td colspan="8">暂无套餐</td></tr>'}
${items.length ? items.map((p, i)=>`<tr data-sp-idx="${i}" style="cursor:pointer"><td>${esc(p.plan_id)}</td><td>${esc(p.lang)}</td><td>${esc(p.title)}<div class="note" style="margin:0">${esc(p.subtitle||'')}</div></td><td>${esc(p.price_display)} (${esc(p.price_cents)})</td><td>${esc(p.currency||'USD')}</td><td>${esc(p.period_days)}</td><td>${esc(p.badge||'')}</td><td>${esc(p.product_id)}</td><td>${esc(p.enabled)}</td></tr>`).join('') : '<tr><td colspan="9">暂无套餐(可点「填入默认价」后保存)</td></tr>'}
</tbody>
</table>`;
const seed = items.find((p) => p.plan_id === 'month' && p.lang === 'zh-CN')
|| items.find((p) => p.lang === 'zh-CN')
|| items[0];
if (seed) fillForm(seed);
else applyPlanDefaults();
$('spPlan').onchange = () => {
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value);
if (hit) fillForm(hit);
else {
$('spTitle').value = ''; $('spSub').value = ''; $('spPrice').value = '';
$('spCents').value = ''; $('spBadge').value = ''; $('spProduct').value = '';
applyPlanDefaults();
}
};
$('spLang').onchange = () => {
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value);
if (hit) fillForm(hit);
};
$('spFillDefaults').onclick = () => {
const d = PLAN_DEFAULTS[$('spPlan').value] || PLAN_DEFAULTS.week;
$('spTitle').value = d.title;
$('spSub').value = d.subtitle;
$('spPrice').value = d.price_display;
$('spCents').value = d.price_cents;
$('spCurrency').value = 'USD';
$('spDays').value = d.period_days;
$('spBadge').value = d.badge;
$('spProduct').value = d.product_id;
$('spSort').value = d.sort_order;
$('spEnabled').checked = true;
};
$('content').querySelectorAll('[data-sp-idx]').forEach((tr) => {
tr.onclick = () => fillForm(items[parseInt(tr.getAttribute('data-sp-idx'), 10)]);
});
$('spSave').onclick = async () => {
const planId = $('spPlan').value;
const d = PLAN_DEFAULTS[planId] || PLAN_DEFAULTS.week;
await api('/spark/subscriptions/plans', { method: 'PUT', body: JSON.stringify({
plan_id: $('spPlan').value, lang: $('spLang').value, title: $('spTitle').value.trim(),
subtitle: $('spSub').value.trim(), price_display: $('spPrice').value.trim(),
price_cents: parseInt($('spCents').value,10)||0, currency: 'CNY',
period_days: parseInt($('spDays').value,10)||7, badge: $('spBadge').value.trim(),
sort_order: parseInt($('spSort').value,10)||1, enabled: !!$('spEnabled').checked,
product_id: $('spProduct').value.trim()
plan_id: planId, lang: $('spLang').value, title: $('spTitle').value.trim() || d.title,
subtitle: $('spSub').value.trim() || d.subtitle,
price_display: $('spPrice').value.trim() || d.price_display,
price_cents: parseInt($('spCents').value, 10) || d.price_cents,
currency: $('spCurrency').value || 'USD',
period_days: parseInt($('spDays').value, 10) || d.period_days,
badge: $('spBadge').value.trim(),
sort_order: parseInt($('spSort').value, 10) || d.sort_order,
enabled: !!$('spEnabled').checked,
product_id: $('spProduct').value.trim() || d.product_id
})});
pages.subPlans();
};

View File

@ -4,8 +4,9 @@
### 概要
- App 展示名统一为 **SleepHeart**;Bundle ID 保持 `health.wyrs365.com`
- App 展示名统一为 **SleepHeart**;Bundle ID 保持 `health.wyrs365.com`;ASC App `6815533078`;Team `49S9UL5MN4`
- spark-server 新增法律文档 / 反馈 / 订阅套餐 / 密码登录 API,并在 popadmin 增加管理页
- 订阅默认价 **USD $1/周 · $3/月 · $12/年**(保留季卡 $8);IAP `health.wyrs365.com.vip.{week|month|quarter|year}`
- HealthiOS 将原先 mock 的密码登录、反馈、订阅配置改为对接真接口(失败仍可 mock 回退)
### 变更范围
@ -19,10 +20,11 @@
### 主要文件
- `migrations/0023_client_legal_feedback_subscription.sql` — 表与种子数据
- `migrations/0025_subscription_plans_usd_pricing.sql` — USD 定价与 IAP product_id 对齐
- `internal/module/client/**` — 业务与仓储
- `internal/transport/http/handler/client`、`handler/admin/client.go`、`router*.go`、`auth.go`
- `deploy/popadmin/popadmin.js` — 三页管理 UI
- `HealthiOS` pbxproj / L10n / AuthService / APIModels
- `deploy/popadmin/popadmin.js` — 三页管理 UI(订阅配置可编辑默认价)
- `HealthiOS` pbxproj / L10n / AuthService / APIModels / PaywallView
### 验证

View File

@ -42,6 +42,8 @@ type Feedback struct {
}
// SubscriptionPlan is one week/month/quarter/year card.
// IAP product_id placeholders: health.wyrs365.com.vip.{week|month|quarter|year}
// (ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com).
type SubscriptionPlan struct {
PlanID string `json:"plan_id"`
Lang string `json:"lang"`

View File

@ -332,25 +332,39 @@ func subscriptionCopy(lang string) (headline, sub string) {
}
func defaultPlans(lang string) []entity.SubscriptionPlan {
const (
pidWeek = "health.wyrs365.com.vip.week"
pidMonth = "health.wyrs365.com.vip.month"
pidQuarter = "health.wyrs365.com.vip.quarter"
pidYear = "health.wyrs365.com.vip.year"
)
mk := func(id, title, sub, price, badge, product string, cents, days, order int) entity.SubscriptionPlan {
return entity.SubscriptionPlan{
PlanID: id, Lang: lang, Title: title, Subtitle: sub, PriceDisplay: price,
PriceCents: cents, Currency: "CNY", PeriodDays: days, Badge: badge,
PriceCents: cents, Currency: "USD", PeriodDays: days, Badge: badge,
SortOrder: order, Enabled: true, ProductID: product,
}
}
if lang == "en" {
return []entity.SubscriptionPlan{
mk("week", "Weekly", "Flexible", "$1.99", "", "health.sub.week", 199, 7, 1),
mk("month", "Monthly", "Most popular", "$4.99", "Popular", "health.sub.month", 499, 30, 2),
mk("quarter", "Quarterly", "Better value", "$12.99", "", "health.sub.quarter", 1299, 90, 3),
mk("year", "Yearly", "Best value", "$29.99", "Best", "health.sub.year", 2999, 365, 4),
mk("week", "Weekly", "Flexible, cancel anytime", "$1/week", "", pidWeek, 100, 7, 1),
mk("month", "Monthly", "Most popular", "$3/month", "Popular", pidMonth, 300, 30, 2),
mk("quarter", "Quarterly", "Better monthly value", "$8/quarter", "", pidQuarter, 800, 90, 3),
mk("year", "Yearly", "Best value all year", "$12/year", "Best", pidYear, 1200, 365, 4),
}
}
if lang == "zh-TW" {
return []entity.SubscriptionPlan{
mk("week", "週卡", "靈活體驗,隨時可停", "$1/週", "", pidWeek, 100, 7, 1),
mk("month", "月卡", "最受歡迎", "$3/月", "熱門", pidMonth, 300, 30, 2),
mk("quarter", "季卡", "平均每月更省", "$8/季", "", pidQuarter, 800, 90, 3),
mk("year", "年卡", "全年暢聽會員曲庫", "$12/年", "超值", pidYear, 1200, 365, 4),
}
}
return []entity.SubscriptionPlan{
mk("week", "周卡", "灵活体验", "¥12", "", "health.sub.week", 1200, 7, 1),
mk("month", "月卡", "最受欢迎", "¥30", "热门", "health.sub.month", 3000, 30, 2),
mk("quarter", "季卡", "更省", "¥68", "", "health.sub.quarter", 6800, 90, 3),
mk("year", "年卡", "全年畅听", "¥198", "超值", "health.sub.year", 19800, 365, 4),
mk("week", "周卡", "灵活体验,随时可停", "$1/周", "", pidWeek, 100, 7, 1),
mk("month", "月卡", "最受欢迎", "$3/月", "热门", pidMonth, 300, 30, 2),
mk("quarter", "季卡", "平均每月更省", "$8/季", "", pidQuarter, 800, 90, 3),
mk("year", "年卡", "全年畅听会员曲库", "$12/年", "超值", pidYear, 1200, 365, 4),
}
}

View File

@ -219,7 +219,7 @@ func (r *Repo) UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error
Enabled: p.Enabled, ProductID: p.ProductID, UpdatedAt: time.Now(),
}
if row.Currency == "" {
row.Currency = "CNY"
row.Currency = "USD"
}
return r.db.WithContext(ctx).Save(&row).Error
}

View File

@ -0,0 +1,103 @@
-- SleepHeart 订阅定价对齐:USD $1/周、$3/月、$12/年(保留季卡 $8);IAP product_id 占位。
-- ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com
UPDATE t_subscription_plans
SET price_display = CASE plan_id
WHEN 'week' THEN '$1/周'
WHEN 'month' THEN '$3/月'
WHEN 'quarter' THEN '$8/季'
WHEN 'year' THEN '$12/年'
ELSE price_display
END,
price_cents = CASE plan_id
WHEN 'week' THEN 100
WHEN 'month' THEN 300
WHEN 'quarter' THEN 800
WHEN 'year' THEN 1200
ELSE price_cents
END,
currency = 'USD',
product_id = CASE plan_id
WHEN 'week' THEN 'health.wyrs365.com.vip.week'
WHEN 'month' THEN 'health.wyrs365.com.vip.month'
WHEN 'quarter' THEN 'health.wyrs365.com.vip.quarter'
WHEN 'year' THEN 'health.wyrs365.com.vip.year'
ELSE product_id
END,
updated_at = CURRENT_TIMESTAMP(3)
WHERE lang = 'zh-CN';
UPDATE t_subscription_plans
SET price_display = CASE plan_id
WHEN 'week' THEN '$1/week'
WHEN 'month' THEN '$3/month'
WHEN 'quarter' THEN '$8/quarter'
WHEN 'year' THEN '$12/year'
ELSE price_display
END,
price_cents = CASE plan_id
WHEN 'week' THEN 100
WHEN 'month' THEN 300
WHEN 'quarter' THEN 800
WHEN 'year' THEN 1200
ELSE price_cents
END,
currency = 'USD',
product_id = CASE plan_id
WHEN 'week' THEN 'health.wyrs365.com.vip.week'
WHEN 'month' THEN 'health.wyrs365.com.vip.month'
WHEN 'quarter' THEN 'health.wyrs365.com.vip.quarter'
WHEN 'year' THEN 'health.wyrs365.com.vip.year'
ELSE product_id
END,
updated_at = CURRENT_TIMESTAMP(3)
WHERE lang = 'en';
UPDATE t_subscription_plans
SET price_display = CASE plan_id
WHEN 'week' THEN '$1/週'
WHEN 'month' THEN '$3/月'
WHEN 'quarter' THEN '$8/季'
WHEN 'year' THEN '$12/年'
ELSE price_display
END,
price_cents = CASE plan_id
WHEN 'week' THEN 100
WHEN 'month' THEN 300
WHEN 'quarter' THEN 800
WHEN 'year' THEN 1200
ELSE price_cents
END,
currency = 'USD',
product_id = CASE plan_id
WHEN 'week' THEN 'health.wyrs365.com.vip.week'
WHEN 'month' THEN 'health.wyrs365.com.vip.month'
WHEN 'quarter' THEN 'health.wyrs365.com.vip.quarter'
WHEN 'year' THEN 'health.wyrs365.com.vip.year'
ELSE product_id
END,
updated_at = CURRENT_TIMESTAMP(3)
WHERE lang = 'zh-TW';
-- 若库中尚无种子,补齐三语默认档(幂等)
INSERT INTO t_subscription_plans
(plan_id, lang, title, subtitle, price_display, price_cents, currency, period_days, badge, sort_order, enabled, product_id, updated_at)
VALUES
('week', 'zh-CN', '周卡', '灵活体验,随时可停', '$1/周', 100, 'USD', 7, '', 1, 1, 'health.wyrs365.com.vip.week', CURRENT_TIMESTAMP(3)),
('month', 'zh-CN', '月卡', '最受欢迎', '$3/月', 300, 'USD', 30, '热门', 2, 1, 'health.wyrs365.com.vip.month', CURRENT_TIMESTAMP(3)),
('quarter', 'zh-CN', '季卡', '平均每月更省', '$8/季', 800, 'USD', 90, '', 3, 1, 'health.wyrs365.com.vip.quarter', CURRENT_TIMESTAMP(3)),
('year', 'zh-CN', '年卡', '全年畅听会员曲库', '$12/年', 1200, 'USD', 365, '超值', 4, 1, 'health.wyrs365.com.vip.year', CURRENT_TIMESTAMP(3)),
('week', 'en', 'Weekly', 'Flexible, cancel anytime', '$1/week', 100, 'USD', 7, '', 1, 1, 'health.wyrs365.com.vip.week', CURRENT_TIMESTAMP(3)),
('month', 'en', 'Monthly', 'Most popular', '$3/month', 300, 'USD', 30, 'Popular', 2, 1, 'health.wyrs365.com.vip.month', CURRENT_TIMESTAMP(3)),
('quarter', 'en', 'Quarterly', 'Better monthly value', '$8/quarter', 800, 'USD', 90, '', 3, 1, 'health.wyrs365.com.vip.quarter', CURRENT_TIMESTAMP(3)),
('year', 'en', 'Yearly', 'Best value all year', '$12/year', 1200, 'USD', 365, 'Best', 4, 1, 'health.wyrs365.com.vip.year', CURRENT_TIMESTAMP(3)),
('week', 'zh-TW', '週卡', '靈活體驗,隨時可停', '$1/週', 100, 'USD', 7, '', 1, 1, 'health.wyrs365.com.vip.week', CURRENT_TIMESTAMP(3)),
('month', 'zh-TW', '月卡', '最受歡迎', '$3/月', 300, 'USD', 30, '熱門', 2, 1, 'health.wyrs365.com.vip.month', CURRENT_TIMESTAMP(3)),
('quarter', 'zh-TW', '季卡', '平均每月更省', '$8/季', 800, 'USD', 90, '', 3, 1, 'health.wyrs365.com.vip.quarter', CURRENT_TIMESTAMP(3)),
('year', 'zh-TW', '年卡', '全年暢聽會員曲庫', '$12/年', 1200, 'USD', 365, '超值', 4, 1, 'health.wyrs365.com.vip.year', CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE
price_display = VALUES(price_display),
price_cents = VALUES(price_cents),
currency = VALUES(currency),
product_id = VALUES(product_id),
updated_at = VALUES(updated_at);