merge: 纳入 @lunar/core 排盘核心与排盘保存显式同意
# Conflicts: # REMINDER.md # web-commercial/CHANGELOG.md # web-commercial/Fetures.md
This commit is contained in:
@@ -7,6 +7,12 @@
|
||||
- 增加 QQ 邮箱 SMTP 登录验证码发送,支持隐式 TLS 与 STARTTLS;SMTP 凭证只从环境变量读取。
|
||||
- SMTP 配置可用时优先发送,未配置时保留 Postmark 备用通道。
|
||||
|
||||
### 排盘数据告知与同意
|
||||
|
||||
- 免费排盘默认只在当前页面展示;用户主动同意后才上传出生资料和排盘快照。
|
||||
- 服务端保存同意版本与时间戳,拒绝缺少同意的写入,并对公开保存接口限流。
|
||||
- 修正“本地计算保护隐私”的误导文案,并在付费下单前说明报告快照会保存到服务端。
|
||||
|
||||
### 排盘记录与免费层
|
||||
|
||||
- 每次排盘保存服务端历史记录,后台新增排盘记录页面。
|
||||
|
||||
+20
-14
@@ -8,25 +8,30 @@
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[填写出生日期、时间、性别、城市] --> B[本地计算真太阳时与八字]
|
||||
A[填写出生日期、时间、性别、城市] --> B[在当前设备计算真太阳时与八字]
|
||||
B --> C[免费查看四柱、五行、大运、流年、刑冲合害、神煞]
|
||||
C --> D{是否解锁深度解读}
|
||||
D -- 否 --> E[继续查看免费排盘]
|
||||
D -- 是 --> F[QQ 邮箱 SMTP 验证码登录]
|
||||
F --> G{选择支付渠道}
|
||||
G --> H[Stripe: 4.99 USD]
|
||||
G --> I[Binance Pay: 5 USDT]
|
||||
H --> J[服务商签名 Webhook]
|
||||
I --> J
|
||||
J --> K[事务更新订单和报告权益]
|
||||
K --> L[生成随机报告恢复码]
|
||||
L --> M[保存不可变报告快照]
|
||||
M --> N[用户凭购买邮箱和恢复码跨设备找回]
|
||||
C --> D{是否同意保存本次排盘记录}
|
||||
D -- 是 --> E[服务端保存所列资料与排盘快照]
|
||||
D -- 否 --> F[只在当前页面查看,不上传免费排盘记录]
|
||||
C --> G{是否解锁深度解读}
|
||||
G -- 否 --> H[继续查看免费排盘]
|
||||
G -- 是 --> I[QQ 邮箱 SMTP 验证码登录并确认下单数据保存说明]
|
||||
I --> J{选择支付渠道}
|
||||
J --> K[Stripe: 4.99 USD]
|
||||
J --> L[Binance Pay: 5 USDT]
|
||||
K --> M[服务商签名 Webhook]
|
||||
L --> M
|
||||
M --> N[事务更新订单和报告权益]
|
||||
N --> O[生成随机报告恢复码]
|
||||
O --> P[保存不可变报告快照]
|
||||
P --> Q[用户凭购买邮箱和恢复码跨设备找回]
|
||||
```
|
||||
|
||||
## 3. 免费排盘内容
|
||||
|
||||
每次排盘都会向服务端保存一份免费快照,记录出生参数、报告编号和计算结果版本,避免用户重复排盘后无法追踪历史。相同输入在同一算法版本下应得到相同结果;算法升级后保留旧快照,不覆盖历史记录。
|
||||
八字运算在当前设备完成。用户默认不上传免费排盘资料;只有主动勾选保存选项后,姓名(如填写)、出生日期、出生时间、性别、城市和排盘快照才会发送到服务端,供授权后台人员查看。未勾选仍可免费计算,但服务端不会创建免费排盘记录。服务端验证同意版本并对公开保存接口限流。相同输入在同一算法版本下应得到相同结果;算法升级后保留已保存快照,不覆盖历史记录。
|
||||
|
||||
购买时会另行告知:创建订单会把报告快照和订单所需信息发送到服务端,用于生成、交付和恢复已购报告;该购买记录不依赖免费排盘保存选项。
|
||||
|
||||
免费层展示基础事实,不把基础数据故意藏到付费层:
|
||||
|
||||
@@ -151,6 +156,7 @@ Stripe 校验 `Stripe-Signature` 与 5 分钟时间窗口;Binance Pay 使用
|
||||
- `report_snapshots`:购买时不可变报告快照。
|
||||
- `payment_events`:Webhook 幂等记录。
|
||||
- `email_login_codes`:一次性验证码。
|
||||
- `bazi_chart_records`:排盘记录增加同意版本与同意时间字段;旧记录不回填为已同意。
|
||||
- `orders` 新增渠道、币种、服务商交易号、报告号、恢复码等字段。
|
||||
|
||||
新部署可使用 GORM AutoMigrate;已有 MySQL 数据库应在备份后执行 `server/migrations/002_commerce.sql`,不要 fresh 或 refresh 数据库。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- Postmark 邮箱验证码登录。
|
||||
- 支付 Webhook 验签与幂等处理。
|
||||
- 支付前只下发免费内容和通用锁定占位,个性化付费解读由服务端在支付确认后生成。
|
||||
- 每次排盘保存服务端历史记录,后台可查询排盘次数与出生参数。
|
||||
- 免费排盘默认不上传;用户主动同意后,服务端保存出生资料、排盘快照及同意版本/时间,授权后台可查询记录。
|
||||
- 已购报告历史列表、随机恢复码与购买时报告快照。
|
||||
- 后台真实订单列表、排盘记录和收入统计。
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
esbuild: true
|
||||
|
||||
+28
-12
@@ -30,6 +30,7 @@ import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core';
|
||||
import {
|
||||
authEmailKey,
|
||||
authTokenKey,
|
||||
chartRecordConsentVersion,
|
||||
checkReportAccess,
|
||||
createCheckout,
|
||||
isDemoPayment,
|
||||
@@ -108,6 +109,8 @@ function App() {
|
||||
const [form, setForm] = useState<BirthForm>(DEFAULT_FORM);
|
||||
const [report, setReport] = useState<ReportState | null>(null);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [consentToSaveChart, setConsentToSaveChart] = useState(false);
|
||||
const [chartSaveStatus, setChartSaveStatus] = useState('');
|
||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||
const [showCheckout, setShowCheckout] = useState(false);
|
||||
const [showRecovery, setShowRecovery] = useState(false);
|
||||
@@ -137,6 +140,8 @@ function App() {
|
||||
|
||||
const calculate = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const shouldSaveChart = consentToSaveChart;
|
||||
setConsentToSaveChart(false);
|
||||
setIsCalculating(true);
|
||||
const [year, month, day] = form.date.split('-').map(Number);
|
||||
const [hour, minute] = form.time.split(':').map(Number);
|
||||
@@ -161,15 +166,20 @@ function App() {
|
||||
const id = createReportId(`${form.date}|${form.time}|${form.gender}|${form.city}`);
|
||||
const chartSnapshot: ReportState = { id, result, elements, shensha, sections: [] };
|
||||
setReport(chartSnapshot);
|
||||
void saveChartRecord({
|
||||
reportId: id,
|
||||
name: form.name,
|
||||
birthDate: form.date,
|
||||
birthTime: form.time,
|
||||
gender: form.gender,
|
||||
city: form.city,
|
||||
snapshot: chartSnapshot,
|
||||
}).catch(() => undefined);
|
||||
setChartSaveStatus(shouldSaveChart ? '正在保存本次排盘记录…' : '本次未上传或保存排盘资料。');
|
||||
if (shouldSaveChart) {
|
||||
void saveChartRecord({
|
||||
reportId: id,
|
||||
name: form.name,
|
||||
birthDate: form.date,
|
||||
birthTime: form.time,
|
||||
gender: form.gender,
|
||||
city: form.city,
|
||||
consentVersion: chartRecordConsentVersion,
|
||||
snapshot: chartSnapshot,
|
||||
}).then(() => setChartSaveStatus('本次排盘记录已保存至服务端,授权后台人员可查看。'))
|
||||
.catch(() => setChartSaveStatus('排盘已完成,但服务端保存失败;本次结果仅保留在当前页面。'));
|
||||
}
|
||||
const localAccess = localStorage.getItem(`lunar-report:${id}`) === 'unlocked';
|
||||
setIsUnlocked(localAccess);
|
||||
const token = localStorage.getItem(authTokenKey);
|
||||
@@ -203,7 +213,7 @@ function App() {
|
||||
<h1>看懂命盘,<br /><em>更懂自己。</em></h1>
|
||||
<p className="hero-lead">输入出生信息,免费查看四柱与五行。完整报告将性格、事业、财富、关系与大运,整理成一份可以反复阅读的人生说明书。</p>
|
||||
<div className="trust-row">
|
||||
<span><ShieldCheck size={17} /> 本地计算保护隐私</span>
|
||||
<span><ShieldCheck size={17} /> 资料保存由你选择</span>
|
||||
<span><WalletCards size={17} /> 一次购买永久回看</span>
|
||||
<span><HeartHandshake size={17} /> 无自动续费</span>
|
||||
</div>
|
||||
@@ -245,10 +255,15 @@ function App() {
|
||||
<ChevronDown size={15} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="privacy-consent">
|
||||
<input type="checkbox" checked={consentToSaveChart} onChange={event => setConsentToSaveChart(event.target.checked)} />
|
||||
<span><strong>同意保存本次排盘资料(可选)</strong>姓名(如填写)、出生日期和时间、性别、城市及排盘结果会发送到服务端保存,授权后台人员可查看。未勾选仍可免费排盘,但不会保存本次记录。</span>
|
||||
</label>
|
||||
<button className="primary-action" type="submit" disabled={isCalculating}>
|
||||
{isCalculating ? <><span className="loading-mark" /> 正在推演命盘...</> : <>免费开始排盘 <ArrowRight size={19} /></>}
|
||||
</button>
|
||||
<p className="form-note"><Fingerprint size={14} /> 出生资料仅在当前设备参与计算,不会公开展示</p>
|
||||
<p className="form-note"><Fingerprint size={14} /> 排盘在当前设备计算;勾选保存后才会上传所列资料。</p>
|
||||
{chartSaveStatus && <p className="chart-save-status" aria-live="polite">{chartSaveStatus}</p>}
|
||||
</form>
|
||||
|
||||
<a className="scroll-cue" href="#how"><span>了解报告</span><ArrowDown size={16} /></a>
|
||||
@@ -631,7 +646,8 @@ function Checkout({ reportId, snapshot, onClose, onPaid }: { reportId: string; s
|
||||
</>
|
||||
)}
|
||||
{error && <p className="payment-error">{error}</p>}
|
||||
<p className="checkout-note"><ShieldCheck /> 服务商签名回调确认到账 · 无自动续费</p>
|
||||
<p className="checkout-note"><ShieldCheck />{isDemoPayment ? '演示支付不会向服务端创建真实订单。' : '下单后,报告快照和订单信息会发送到服务端保存,用于生成、交付和恢复已购报告。'}</p>
|
||||
<p className="checkout-note">服务商签名回调确认到账 · 无自动续费</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ interface ApiPayload<T> {
|
||||
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
export const isDemoPayment = import.meta.env.VITE_PAYMENT_MODE === 'demo';
|
||||
export const chartRecordConsentVersion = 'chart-record-v1';
|
||||
export const authTokenKey = 'lunar-commerce-token';
|
||||
export const authEmailKey = 'lunar-commerce-email';
|
||||
|
||||
@@ -45,6 +46,7 @@ export async function saveChartRecord(input: {
|
||||
birthTime: string;
|
||||
gender: string;
|
||||
city: string;
|
||||
consentVersion: string;
|
||||
snapshot: unknown;
|
||||
}): Promise<void> {
|
||||
await apiRequest<never>('/api/commerce/charts', {
|
||||
|
||||
@@ -187,6 +187,10 @@ header nav a:hover { color: #fff; }
|
||||
.primary-action:hover, .offer-card button:hover, .unlock-panel button:hover, .pay-button:hover { background: var(--vermilion-dark); transform: translateY(-1px); }
|
||||
.primary-action:disabled, .pay-button:disabled { opacity: .7; cursor: wait; }
|
||||
.form-note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 14px 0 0; font-size: 10px; color: #929086; }
|
||||
.privacy-consent { display: flex; align-items: flex-start; gap: 9px; margin: 16px 0 18px; color: #77786f; font-size: 10px; line-height: 1.7; cursor: pointer; }
|
||||
.privacy-consent input { flex: 0 0 auto; width: 15px; height: 15px; margin: 2px 0 0; accent-color: var(--vermilion); }
|
||||
.privacy-consent strong { display: block; margin-bottom: 2px; color: var(--ink); font-size: 11px; }
|
||||
.chart-save-status { margin: 9px 0 0; color: var(--ink-soft); text-align: center; font-size: 10px; line-height: 1.6; }
|
||||
.loading-mark { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
.scroll-cue { position: absolute; bottom: 27px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.42); font-size: 9px; letter-spacing: .2em; writing-mode: vertical-rl; }
|
||||
.scroll-cue svg { animation: bob 1.8s ease-in-out infinite; }
|
||||
|
||||
Reference in New Issue
Block a user