初始提交:岩美 pay 收款服务(支付宝当面付 + 多商户多渠道架构)

- Go/Gin/GORM + 纯 Go SQLite(无 cgo)
- Channel 多渠道接口:支付宝当面付(precreate)/电脑网站支付(page.pay) 已实现,微信占位
- 多商户 merchants 表,回调验签+金额核对+幂等+查单兜底
- 收款页/结果页/二维码端点;docs/ 设计文档与部署 Runbook
- 密钥走环境变量/Bitwarden,不入库

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-25 07:21:48 +08:00
commit 6da6964451
29 changed files with 2205 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>收款</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--radius:14px}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:radial-gradient(1000px 500px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;display:flex;align-items:center;justify-content:center;padding:24px}
.box{width:100%;max-width:420px}
h1{font-size:22px;margin:0 0 4px;text-align:center}
.sub{color:var(--muted);font-size:13px;text-align:center;margin-bottom:22px}
.item{display:flex;justify-content:space-between;align-items:center;gap:12px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin:12px 0;cursor:pointer;transition:.15s}
.item:hover{border-color:var(--accent);background:var(--card-2)}
.item.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset}
.item .name{font-weight:600}
.item .desc{color:var(--fg-soft);font-size:13px;margin-top:2px}
.item .price{color:var(--green);font-size:20px;font-weight:700;white-space:nowrap}
.item .price small{font-size:12px;color:var(--muted);font-weight:400}
button{width:100%;margin-top:18px;padding:14px;border:0;border-radius:12px;background:var(--accent);color:#04122b;font-size:16px;font-weight:700;cursor:pointer;transition:.15s}
button:disabled{opacity:.5;cursor:not-allowed}
.msg{margin-top:14px;text-align:center;font-size:13px;color:var(--muted);min-height:18px}
.msg.err{color:#f85149}
.hint{margin-top:16px;font-size:12.5px;color:var(--muted);text-align:center;line-height:1.7}
.hint code{background:var(--card-2);border:1px solid var(--border);border-radius:4px;padding:1px 5px;color:var(--fg-soft)}
</style>
</head>
<body>
<div class="box">
<h1>选择套餐</h1>
<div class="sub">支付宝支付 · 沙箱联调</div>
<div id="list"></div>
<button id="pay" disabled>请选择套餐</button>
<div class="msg" id="msg"></div>
<div class="hint">
跳转到支付宝收银台后,用<strong>右边「登录支付宝账户付款」</strong><br>
买家账号 <code>vtlvff1163@sandbox.com</code> · 支付密码 <code>111111</code>
</div>
</div>
<script>
let selected = null;
const listEl = document.getElementById('list');
const payBtn = document.getElementById('pay');
const msgEl = document.getElementById('msg');
function setMsg(t, err){ msgEl.textContent = t || ''; msgEl.classList.toggle('err', !!err); }
async function load(){
try{
const r = await fetch('/api/v1/products');
const j = await r.json();
const items = j.data || [];
if(!items.length){ listEl.innerHTML = '<div class="sub">暂无可购套餐</div>'; return; }
listEl.innerHTML = '';
items.forEach(p => {
const el = document.createElement('div');
el.className = 'item';
el.innerHTML = `<div><div class="name">${p.name}</div><div class="desc">${p.description||''}</div></div>
<div class="price">¥${p.price}<small> 元</small></div>`;
el.onclick = () => {
document.querySelectorAll('.item').forEach(x=>x.classList.remove('sel'));
el.classList.add('sel');
selected = p;
payBtn.disabled = false;
payBtn.textContent = `支付 ¥${p.price}`;
};
listEl.appendChild(el);
});
}catch(e){ setMsg('加载套餐失败:'+e, true); }
}
payBtn.onclick = async () => {
if(!selected) return;
payBtn.disabled = true; setMsg('正在创建订单…');
try{
const r = await fetch('/api/v1/orders', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({product_id: selected.id})
});
const j = await r.json();
if(!r.ok){ setMsg(j.message || '下单失败', true); payBtn.disabled=false; return; }
setMsg('正在跳转支付宝收银台…');
window.location.href = j.data.pay_url;
}catch(e){ setMsg('下单异常:'+e, true); payBtn.disabled=false; }
};
load();
</script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>支付结果</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--orange:#d29922;--radius:14px}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:radial-gradient(1000px 500px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;display:flex;align-items:center;justify-content:center;padding:24px}
.card{width:100%;max-width:420px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:30px 26px;text-align:center}
.icon{font-size:52px;line-height:1;margin-bottom:10px}
h1{font-size:21px;margin:6px 0 16px}
.row{display:flex;justify-content:space-between;padding:9px 0;border-top:1px solid var(--border);font-size:14px}
.row .k{color:var(--muted)}
.row .v{color:var(--fg-soft);word-break:break-all;text-align:right;max-width:60%}
.paid{color:var(--green)} .pending{color:var(--orange)}
a{display:inline-block;margin-top:20px;color:var(--accent);text-decoration:none;font-size:14px}
.hint{color:var(--muted);font-size:12.5px;margin-top:8px}
</style>
</head>
<body>
<div class="card">
<div class="icon" id="icon"></div>
<h1 id="title">正在确认支付结果…</h1>
<div id="detail"></div>
<div class="hint" id="hint">到账以异步通知为准,正在轮询…</div>
<a href="/">← 返回收款页</a>
</div>
<script>
const params = new URLSearchParams(location.search);
const out = params.get('out_trade_no');
const iconEl = document.getElementById('icon');
const titleEl = document.getElementById('title');
const detailEl = document.getElementById('detail');
const hintEl = document.getElementById('hint');
function render(o){
const paid = o.status === 'paid';
iconEl.textContent = paid ? '✅' : '⏳';
titleEl.textContent = paid ? '支付成功' : '等待支付确认';
titleEl.className = paid ? 'paid' : 'pending';
detailEl.innerHTML = `
<div class="row"><span class="k">商品</span><span class="v">${o.subject||'-'}</span></div>
<div class="row"><span class="k">金额</span><span class="v">¥${o.amount}</span></div>
<div class="row"><span class="k">状态</span><span class="v ${paid?'paid':'pending'}">${paid?'已支付':'待支付'}</span></div>
<div class="row"><span class="k">订单号</span><span class="v">${o.out_trade_no}</span></div>
${o.trade_no?`<div class="row"><span class="k">支付宝流水</span><span class="v">${o.trade_no}</span></div>`:''}`;
return paid;
}
let tries = 0;
async function poll(){
if(!out){ titleEl.textContent='缺少订单号'; iconEl.textContent='⚠️'; hintEl.textContent=''; return; }
try{
const r = await fetch('/api/v1/orders/'+encodeURIComponent(out));
const j = await r.json();
if(r.ok){
const paid = render(j.data);
if(paid){ hintEl.textContent=''; return; }
}
}catch(e){}
tries++;
if(tries < 10){ setTimeout(poll, 2000); }
else { hintEl.textContent = '尚未确认到账。异步通知可能稍有延迟,可稍后刷新本页。'; }
}
poll();
</script>
</body>
</html>