feat(commerce): 排盘保存需显式同意,默认本地计算不上传

- 前端免费排盘默认不保存,勾选同意后才调用保存接口
- 服务端要求 consentVersion,记录 consentAt,校验并限流公开保存接口
- 付费结算单独披露报告快照与订单信息将由服务端存储
- 管理端展示同意版本与时间,历史无同意证据记录保留并标注

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
gouki
2026-09-27 23:21:16 +00:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent ea6828b925
commit 132fef051b
14 changed files with 201 additions and 58 deletions
+28 -12
View File
@@ -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>
);
+2
View File
@@ -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', {
+4
View File
@@ -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; }