更新最新代码

This commit is contained in:
CodeBuddy
2026-10-26 17:32:47 +08:00
commit 0f5dc01674
315 changed files with 42640 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# IDE
.idea/
# 运行产物
run/
*.log
# 本地配置(含密钥,不入库)
config.yaml
# 依赖与构建产物
node_modules/
dist/
dist-ssr/
# 系统文件
.DS_Store
Thumbs.db
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
<div id="couple-journal" class="viz-dotted-background">
<div class="preview-controls" aria-label="设计稿状态">
<label>展示状态 <select id="journal-state"><option value="filled">有记录 · 已绑定</option><option value="empty">无记录 · 已绑定</option><option value="unbound">未绑定</option><option value="denied">对方关闭姨姨可见</option></select></label>
<span>设计示例</span>
</div>
<div class="journal-phone" aria-label="精致情侣手账设计稿">
<div class="phone-status"><span>09:41</span><span>••• ▰</span></div>
<header class="phone-nav"><button id="journal-back" aria-label="返回首页" hidden>‹</button><strong id="journal-title">我们</strong><span class="capsule">••• <b>◉</b></span></header>
<main id="journal-content"></main>
<nav class="phone-tabs" aria-label="小程序导航"><button data-page="home"><i data-lucide="house"></i><span>我们</span></button><button data-page="records"><i data-lucide="notebook-pen"></i><span>记录</span></button><button data-page="profile"><i data-lucide="user-round"></i><span>我的</span></button></nav>
<div class="home-indicator"></div>
<div id="journal-sheet" class="sheet-mask" hidden><section class="journal-sheet" role="dialog" aria-modal="true" aria-labelledby="sheet-title"><div class="sheet-grip"></div><header><h3 id="sheet-title"></h3><button id="sheet-close" aria-label="关闭">×</button></header><div id="sheet-content"></div></section></div>
</div>
</div>
<style>
#couple-journal {--j-bg:#faf5ee;--j-paper:#fffdf8;--j-ink:#352e2a;--j-muted:#85776d;--j-rose:#b74768;--j-soft:#f4e2e3;--j-line:#e9ddd0;--j-white:#fff;--j-green:#658776;--j-shadow:0 8px 25px #72524012;color:var(--j-ink);font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;padding:20px 10px 28px;color-scheme:light}
#couple-journal *{box-sizing:border-box}#couple-journal button,#couple-journal select{font:inherit}#couple-journal button{cursor:inherit}#couple-journal button:focus-visible,#couple-journal select:focus-visible{outline:3px solid var(--j-rose);outline-offset:3px}#couple-journal [hidden]{display:none!important}
#couple-journal .preview-controls{max-width:390px;margin:0 auto 20px;display:flex;justify-content:space-between;gap:10px;align-items:center;font-size:12px;color:var(--j-muted)}#couple-journal select{border:1px solid var(--j-line);background:var(--j-paper);color:var(--j-ink);border-radius:9px;padding:9px;max-width:230px;font-size:12px}#couple-journal .preview-controls label{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
#couple-journal .journal-phone{max-width:390px;margin:auto;background:var(--j-bg);border:1px solid var(--j-line);border-radius:32px;overflow:hidden;position:relative;box-shadow:0 20px 60px #46332318;min-height:830px}#couple-journal .phone-status{padding:19px 25px 8px;display:flex;justify-content:space-between;font-size:12px;font-weight:600}#couple-journal .phone-nav{height:54px;display:flex;align-items:center;justify-content:center;position:relative}#couple-journal .phone-nav strong{font-size:18px;letter-spacing:2px}#couple-journal .capsule{position:absolute;right:16px;font-size:18px;letter-spacing:1px;border:1px solid var(--j-line);border-radius:24px;padding:4px 10px}#couple-journal .capsule b{font-weight:400;border-left:1px solid var(--j-line);margin-left:7px;padding-left:8px}#couple-journal #journal-back{position:absolute;left:18px;background:none;border:0;font-size:29px;width:40px;height:44px;color:var(--j-ink)}
#couple-journal main{padding:20px 22px 28px;min-height:655px}#couple-journal .eyebrow{font-size:11px;letter-spacing:2px;color:var(--j-muted);text-transform:uppercase}#couple-journal .head-row{display:flex;justify-content:space-between;align-items:center;gap:12px}#couple-journal h2{font-size:21px;margin:6px 0 20px;font-weight:600;letter-spacing:1px}#couple-journal h3{font-size:16px;margin:0;font-weight:600}#couple-journal p{font-size:13px;line-height:1.8;margin:8px 0;color:var(--j-muted)}#couple-journal .quiet-link{background:none;border:0;color:var(--j-rose);font-size:12px;padding:8px 0;min-height:44px}#couple-journal .rose-button{border:0;background:var(--j-rose);color:var(--j-white);border-radius:14px;padding:12px 18px;font-size:13px;min-height:44px}#couple-journal .outline-button{background:var(--j-paper);color:var(--j-rose);border:1px solid var(--j-line);border-radius:12px;padding:11px 15px;min-height:44px;font-size:13px}
#couple-journal .space-cover{position:relative;margin:6px 0 27px;padding:25px 24px 20px;border-radius:6px 30px 6px 30px;background:var(--j-paper);box-shadow:var(--j-shadow);border:1px solid var(--j-line)}#couple-journal .space-cover:before{content:"";position:absolute;left:calc(50% - 28px);top:-9px;width:56px;height:20px;background:#e9d5b7bb;transform:rotate(-5deg);clip-path:polygon(3% 0,100% 3%,97% 96%,0 100%)}#couple-journal .cover-top{display:flex;justify-content:space-between;align-items:center}#couple-journal .little-heart{color:var(--j-rose);transform:rotate(12deg)}#couple-journal .days{font-family:Georgia,serif;font-size:76px;line-height:1.08;letter-spacing:-4px;margin-top:12px;color:var(--j-rose)}#couple-journal .days span{font-family:inherit;font-size:14px;letter-spacing:0;color:var(--j-muted);margin-left:12px}#couple-journal .cover-date{font-size:11px;color:var(--j-muted);letter-spacing:1px;margin-top:9px}#couple-journal .couple-line{display:flex;gap:10px;align-items:center;border-top:1px dashed var(--j-line);padding-top:16px;margin-top:21px;font-size:12px}#couple-journal .avatar{width:33px;height:33px;border-radius:50%;background:var(--j-soft);display:grid;place-items:center;color:var(--j-rose);font-size:13px;border:3px solid var(--j-paper)}#couple-journal .avatar.second{background:#e4ebe3;color:#526b57;margin-left:-15px}#couple-journal .couple-line .distance{margin-left:auto;color:var(--j-muted);font-size:11px}
#couple-journal .memory{display:flex;align-items:center;gap:15px;margin:0 0 27px;padding:0;border:0;text-align:left;background:none;color:var(--j-ink);width:100%;min-height:60px}#couple-journal .calendar-stamp{width:46px;flex-shrink:0;border:1px solid var(--j-line);background:var(--j-paper);text-align:center;border-radius:7px;transform:rotate(-5deg)}#couple-journal .calendar-stamp small{display:block;font-size:9px;padding:4px;background:var(--j-soft);color:var(--j-rose)}#couple-journal .calendar-stamp b{display:block;font-family:Georgia,serif;font-size:24px;padding:4px;font-weight:400}#couple-journal .memory b{font-size:14px;font-weight:500}#couple-journal .memory p{margin:3px 0;font-size:11px}#couple-journal .memory .countdown{margin-left:auto;font-family:Georgia,serif;font-size:29px;color:var(--j-rose)}#couple-journal .countdown small{font-family:sans-serif;font-size:11px;margin-left:4px}
#couple-journal .shortcuts{display:flex;justify-content:space-between;margin-bottom:30px;border-top:1px solid var(--j-line);border-bottom:1px solid var(--j-line);padding:15px 0}#couple-journal .shortcuts button{background:none;border:0;color:var(--j-ink);font-size:11px;display:flex;align-items:center;gap:7px;min-height:44px;padding:0 3px}#couple-journal .shortcuts svg{width:17px;height:17px;color:var(--j-rose)}#couple-journal .section-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}#couple-journal .paper-story{position:relative;background:var(--j-paper);padding:16px;box-shadow:var(--j-shadow);border:1px solid var(--j-line);border-radius:5px;transform:rotate(-1deg);margin-bottom:21px;text-align:left;width:100%;color:var(--j-ink)}#couple-journal .paper-story time{font-family:Georgia,serif;font-size:12px;letter-spacing:1px;color:var(--j-muted)}#couple-journal .paper-story h3{margin:9px 0 8px}#couple-journal .paper-story p{font-size:12px;margin:6px 0 11px}#couple-journal .story-footer{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--j-muted);padding-top:8px}
#couple-journal .landscape{height:147px;overflow:hidden;position:relative;background:linear-gradient(#e2bfac 0%,#f4d8ba 48%,#92aaa9 49%,#c4d1c7 100%);border-radius:2px;margin:8px 0}#couple-journal .landscape:before{content:"";position:absolute;width:180px;height:170px;border-radius:45% 60% 0 0;background:#788e81;left:-30px;top:65px;transform:rotate(30deg)}#couple-journal .landscape:after{content:"";position:absolute;width:200px;height:140px;border-radius:70% 60% 0 0;background:#556f68;right:-40px;top:85px;transform:rotate(-20deg)}#couple-journal .sun{position:absolute;width:29px;height:29px;border-radius:50%;background:#fff1d8;right:72px;top:27px;box-shadow:0 0 32px #fff1d899}#couple-journal .photo-label{position:absolute;bottom:10px;left:12px;color:#fffdf8;z-index:1;font-size:10px;letter-spacing:2px}
#couple-journal .empty-note{text-align:center;padding:35px 15px;background:var(--j-paper);border:1px dashed var(--j-line);border-radius:6px 25px 6px 25px;margin-top:18px}#couple-journal .empty-note>svg{color:var(--j-rose);width:30px;height:30px;margin-bottom:15px}#couple-journal .empty-note h3{font-size:17px}#couple-journal .empty-note p{font-size:12px;margin:10px 0 20px}#couple-journal .invite{border:1px dashed var(--j-line);padding:17px;border-radius:16px;margin-bottom:22px;display:flex;align-items:center;justify-content:space-between;gap:12px}#couple-journal .invite h3{font-size:13px}#couple-journal .invite p{font-size:11px;margin:3px 0}
#couple-journal .record-filters{display:flex;gap:20px;border-bottom:1px solid var(--j-line);margin-bottom:24px}#couple-journal .record-filters button{background:none;border:0;color:var(--j-muted);padding:12px 2px;min-height:44px;font-size:13px}#couple-journal .record-filters button.active{border-bottom:2px solid var(--j-rose);color:var(--j-rose);font-weight:600}#couple-journal .month-label{font-family:Georgia,serif;font-size:28px;letter-spacing:-1px;margin:0 0 18px}#couple-journal .month-label small{font-family:sans-serif;font-size:12px;letter-spacing:0;color:var(--j-muted);margin-left:10px}#couple-journal .tools{margin-top:27px;padding-top:20px;border-top:1px solid var(--j-line)}#couple-journal .tool-list{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:15px}#couple-journal .tool-list button{background:var(--j-paper);border:1px solid var(--j-line);border-radius:12px;color:var(--j-muted);font-size:11px;padding:13px 3px;display:flex;align-items:center;gap:7px;flex-direction:column;min-height:60px}#couple-journal .tool-list svg{width:19px;height:19px;color:var(--j-rose)}
#couple-journal .period-summary{margin:3px 0 25px}#couple-journal .period-summary h2{font-size:28px;margin:8px 0;color:var(--j-rose)}#couple-journal .period-summary p{font-size:12px}#couple-journal .calendar-paper{background:var(--j-paper);border:1px solid var(--j-line);border-radius:22px;padding:18px 13px;box-shadow:var(--j-shadow)}#couple-journal .calendar-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}#couple-journal .calendar-heading b{font-size:15px;font-weight:600}#couple-journal .calendar-heading button{background:none;border:0;color:var(--j-muted);width:44px;height:44px;font-size:21px}#couple-journal .calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px 0}#couple-journal .calendar-grid small{text-align:center;font-size:10px;color:var(--j-muted);padding-bottom:8px}#couple-journal .calendar-grid button{background:none;border:0;font-size:12px;min-height:40px;color:var(--j-ink);border-radius:10px;padding:0;position:relative}#couple-journal .calendar-grid button.period-day{background:var(--j-soft);color:var(--j-rose)}#couple-journal .calendar-grid button.predicted{background:#f8eeeb;color:#956571}#couple-journal .calendar-grid button.selected{background:var(--j-rose);color:var(--j-white);box-shadow:0 3px 8px #b7476830}#couple-journal .calendar-grid button.recorded:after{content:"";position:absolute;bottom:4px;left:calc(50% - 2px);width:4px;height:4px;background:currentColor;border-radius:50%}#couple-journal .calendar-legend{display:flex;justify-content:center;gap:17px;margin-top:17px;font-size:10px;color:var(--j-muted)}#couple-journal .calendar-legend span:before{content:"";display:inline-block;width:7px;height:7px;margin-right:5px;background:var(--j-soft);border-radius:2px}#couple-journal .calendar-legend span+span:before{background:#f8eeeb}#couple-journal .day-details{margin-top:26px}#couple-journal .day-detail-row{display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--j-line);padding:14px 0;font-size:12px}#couple-journal .day-detail-row svg{color:var(--j-rose);width:17px;height:17px}#couple-journal .day-detail-row span:last-child{margin-left:auto;color:var(--j-muted)}#couple-journal .permission-row{display:flex;align-items:center;justify-content:space-between;padding:18px 0;font-size:12px;border-top:1px solid var(--j-line);margin-top:24px}#couple-journal .permission-row p{font-size:11px;margin:3px 0}#couple-journal .toggle{border:0;width:42px;height:26px;border-radius:16px;background:var(--j-rose);padding:3px;display:flex;justify-content:flex-end;flex-shrink:0}#couple-journal .toggle.off{background:#b6a99b;justify-content:flex-start}#couple-journal .toggle:before{content:"";width:20px;height:20px;background:var(--j-white);border-radius:50%}
#couple-journal .phone-tabs{display:flex;background:var(--j-paper);border-top:1px solid var(--j-line);padding:12px 22px 5px;justify-content:space-around}#couple-journal .phone-tabs button{background:none;border:0;display:flex;flex-direction:column;align-items:center;gap:5px;color:var(--j-muted);font-size:10px;min-width:66px;min-height:44px}#couple-journal .phone-tabs svg{width:20px;height:20px}#couple-journal .phone-tabs button.active{color:var(--j-rose)}#couple-journal .home-indicator{width:100px;height:4px;border-radius:4px;background:var(--j-ink);margin:11px auto 9px}#couple-journal .sheet-mask{position:absolute;inset:0;background:#352e2a55;z-index:3;display:flex;align-items:flex-end}#couple-journal .journal-sheet{width:100%;background:var(--j-paper);border-radius:25px 25px 0 0;padding:10px 24px 30px;box-shadow:0 -10px 35px #352e2a18}#couple-journal .sheet-grip{width:32px;height:4px;border-radius:3px;background:var(--j-line);margin:0 auto 20px}#couple-journal .journal-sheet header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}#couple-journal .journal-sheet header button{background:none;border:0;color:var(--j-muted);font-size:23px;width:44px;height:44px}#couple-journal .sheet-actions{display:grid;grid-template-columns:1fr 1fr;gap:12px}#couple-journal .sheet-actions button{display:flex;flex-direction:column;align-items:flex-start;gap:10px;background:var(--j-bg);color:var(--j-ink);border:1px solid var(--j-line);border-radius:15px;padding:17px;font-size:13px}#couple-journal .sheet-actions svg{color:var(--j-rose)}#couple-journal .sheet-field{display:block;font-size:12px;color:var(--j-muted);margin:14px 0}#couple-journal .sheet-field input,#couple-journal .sheet-field textarea{display:block;width:100%;margin-top:8px;border:1px solid var(--j-line);background:var(--j-bg);color:var(--j-ink);border-radius:11px;padding:12px;font:inherit;font-size:16px}#couple-journal .sheet-field textarea{min-height:100px;resize:vertical}
@media(max-width:360px){#couple-journal{padding:12px 0}#couple-journal main{padding:18px 17px}#couple-journal .shortcuts button{gap:4px;font-size:10px}#couple-journal .preview-controls{padding:0 5px}#couple-journal .days{font-size:68px}#couple-journal .calendar-grid button{min-height:38px}}
</style>
<script>
(()=>{
const root=document.getElementById('couple-journal'),content=root.querySelector('#journal-content'),picker=root.querySelector('#journal-state'),sheet=root.querySelector('#journal-sheet');
const saved=window.openai?.widgetState?.modelContent||{};
const state={page:['home','records','period','profile'].includes(saved.page)?saved.page:'home',scenario:['filled','empty','unbound','denied'].includes(saved.scenario)?saved.scenario:'filled',filter:'日记',day:5,visible:true,accent:'#b74768',paper:true};
const icon=name=>`<i data-lucide="${name}" aria-hidden="true"></i>`;
const scene=()=>'<div class="landscape" role="img" aria-label="海边日落示意插画"><span class="sun"></span><span class="photo-label">海边的傍晚</span></div>';
const story=()=>`<button class="paper-story" data-action="story"><time>10.04 / SUNDAY</time><h3>把日落装进口袋</h3><p>沿着海边走了很久,回头的时候,刚好看见你在笑。</p>${scene()}<div class="story-footer">${icon('heart')} 小满 · 昨天 18:32</div></button>`;
const empty=(title,desc,action='留下第一篇',key='publish')=>`<div class="empty-note">${icon('notebook-pen')}<h3>${title}</h3><p>${desc}</p><button class="rose-button" data-action="${key}">${action}</button></div>`;
const tools=()=>`<div class="tools"><h3>一起的小事</h3><div class="tool-list">${[['music-2','歌曲','songs'],['circle-check','打卡','checkin'],['flower-2','姨姨','period'],['paw-print','宠物','pet']].map(([i,n,a])=>`<button data-action="${a}">${icon(i)}${n}</button>`).join('')}</div></div>`;
function home(){const unbound=state.scenario==='unbound';return `<div class="eyebrow">OUR LITTLE WORLD</div><h2>${unbound?'给日常留一点位置':'平凡的日子,也值得记住'}</h2><section class="space-cover"><div class="cover-top"><span class="eyebrow">${unbound?'我的生活手账':'我们在一起'}</span><span class="little-heart">${icon('heart')}</span></div><div class="days">${unbound?'124':'365'}<span>天</span></div><div class="cover-date">${unbound?'注册于 2026.06.03':'故事开始于 2025.10.05'}</div><div class="couple-line"><span class="avatar">满</span>${unbound?'': '<span class="avatar second">屿</span>'}<span>${unbound?'小满':'小满 & 阿屿'}</span><span class="distance">${unbound?'等待另一半加入':'把日子慢慢过成故事'}</span></div></section>${unbound?'<div class="invite"><div><h3>邀请另一半,共写这本手账</h3><p>绑定后,一起收藏日常</p></div><button class="outline-button" data-action="bind">邀请</button></div>':'<button class="memory" data-action="memory"><span class="calendar-stamp"><small>OCT</small><b>12</b></span><span><b>下一次值得期待</b><p>第一次旅行纪念日</p></span><span class="countdown">7<small>天后</small></span></button>'}<div class="shortcuts">${[['calendar-heart','记忆日','memory'],['images','相册','albums'],['notebook-pen','写日记','publish'],['flower-2','姨姨','period']].map(([i,n,a])=>`<button data-action="${a}">${icon(i)}${n}</button>`).join('')}</div><div class="section-top"><h3>最近的一页</h3><button class="quiet-link" data-action="records">翻翻手账 →</button></div>${state.scenario==='filled'||state.scenario==='denied'?story():empty('故事,从今天开始','一句心情、一张照片,都可以成为第一页。')}`;}
function records(){let body;if(state.scenario==='empty'||state.scenario==='unbound')body=empty('还没有'+state.filter,'慢慢积攒属于你们的生活片段。',state.filter==='相册'?'创建相册':state.filter==='记忆日'?'添加记忆日':'写日记',state.filter==='相册'?'newAlbum':state.filter==='记忆日'?'newMemory':'publish');else if(state.filter==='日记')body='<div class="month-label">October<small>2026 · 十月</small></div>'+story()+'<button class="paper-story" data-action="story"><time>10.02 / FRIDAY</time><h3>今天你做的饭,满分</h3><p>没有特别的安排。两个人一起买菜、做饭,就已经是很好的周末。</p><div class="story-footer">'+icon('heart')+' 阿屿 · 10月2日</div></button>';else if(state.filter==='相册')body='<button class="paper-story" data-action="album">'+scene()+'<h3>沿着海岸线</h3><p>12 张照片 · 10月4日更新</p></button>';else body='<button class="memory" data-action="memory"><span class="calendar-stamp"><small>OCT</small><b>12</b></span><span><b>第一次旅行</b><p>每一年,都想和你重游一次</p></span><span class="countdown">7<small>天后</small></span></button>';return `<div class="head-row"><div><span class="eyebrow">PAGES OF US</span><h2>我们的生活手账</h2></div><button class="rose-button" data-action="publish">+ 记录</button></div><div class="record-filters">${['日记','相册','记忆日'].map(n=>`<button data-filter="${n}" class="${state.filter===n?'active':''}">${n}</button>`).join('')}</div>${body}${tools()}`;}
function permission(){return `<div class="permission-row"><div>对方可见<p>关闭后,对方无法查看这份生理记录</p></div><button class="toggle ${state.visible?'':'off'}" role="switch" aria-checked="${state.visible}" aria-label="对方可见" data-action="toggle"></button></div>`;}
function period(){if(state.scenario==='denied')return empty('对方暂时关闭了可见','这份生理记录目前不可查看,<br>需要对方重新开启后才能访问。','刷新权限','refresh')+permission();if(state.scenario==='empty'||state.scenario==='unbound')return `<div class="period-summary"><span class="eyebrow">BODY JOURNAL</span><h2>慢慢了解自己的节奏</h2><p>从记录一次生理期开始。</p></div>${empty('还没有生理期记录','填写最近一次生理期,开始记录身体状态。','设置生理期','settings')}${permission()}`;const days=Array.from({length:35},(_,i)=>{const d=i-3;return d<1||d>31?'<span></span>':`<button data-day="${d}" aria-label="10月${d}日${d<=5?' 经期':''}" class="${d<=5?'period-day':''} ${d>=29?'predicted':''} ${d===state.day?'selected':''} ${[2,5].includes(d)?'recorded':''}">${d}</button>`}).join('');return `<div class="period-summary"><div class="head-row"><span class="eyebrow">BODY JOURNAL</span><button class="quiet-link" data-action="settings">周期设置 ${icon('settings-2')}</button></div><h2>生理期第 5 天</h2><p>平均周期 28 天 · 下次预计 10月29日</p></div><section class="calendar-paper"><div class="calendar-heading"><button data-action="calendarHelp" aria-label="上个月">‹</button><b>2026年 10月</b><button data-action="calendarHelp" aria-label="下个月">›</button></div><div class="calendar-grid">${['日','一','二','三','四','五','六'].map(d=>'<small>'+d+'</small>').join('')}${days}</div><div class="calendar-legend"><span>经期</span><span>预测经期</span></div></section><div class="day-details"><div class="section-top"><h3>10月${state.day}日 · 当天记录</h3><button class="quiet-link" data-action="bodyRecord">+ 添加</button></div>${[2,5].includes(state.day)?`<div class="day-detail-row">${icon('flower-2')}<span>经量</span><span>少量</span></div><div class="day-detail-row">${icon('smile')}<span>心情</span><span>平静</span></div><div class="day-detail-row">${icon('notebook-pen')}<span>备注</span><span>今天状态不错</span></div>`:'<p>这一天还没有记录,留意一下身体的感受。</p><button class="outline-button" data-action="bodyRecord">记录当天状态</button>'}</div>${permission()}`;}
function profile(){return `<div class="eyebrow">MY CORNER</div><h2>我的小角落</h2><div class="space-cover"><div class="head-row"><div><h3>小满</h3><p>ID 520087</p></div><span class="avatar">满</span></div><button class="quiet-link" data-action="editProfile">编辑资料 →</button></div><h3>我们的关系</h3>${state.scenario==='unbound'?'<div class="invite"><p>另一半还没有加入</p><button class="outline-button" data-action="bind">绑定情侣</button></div>':'<div class="permission-row"><span>小满 & 阿屿<p>在一起 365 天</p></span><button class="quiet-link" data-action="relationship">关系管理 →</button></div>'}<div class="permission-row"><span>外观与通知</span><button class="quiet-link" data-action="appearance">设置 →</button></div>`;}
function render(){root.style.setProperty('--j-rose',state.accent);root.querySelectorAll('.paper-story,.space-cover').forEach(e=>e.style.transform=state.paper?'':'none');picker.value=state.scenario;content.innerHTML=({home,records,period,profile}[state.page])();root.querySelector('#journal-title').textContent={home:'我们',records:'记录',period:'姨姨',profile:'我的'}[state.page];root.querySelector('#journal-back').hidden=state.page!=='period';root.querySelectorAll('[data-page]').forEach(b=>{b.classList.toggle('active',b.dataset.page===(state.page==='period'?'records':state.page));b.setAttribute('aria-current',b.classList.contains('active')?'page':'false')});if(!state.paper)root.querySelectorAll('.paper-story,.space-cover').forEach(e=>e.style.transform='none');globalThis.lucide?.createIcons();}
function persist(){window.openai?.setWidgetState?.({modelContent:{page:state.page,scenario:state.scenario},privateContent:null})?.catch(()=>{});}
function openSheet(title,body){root.querySelector('#sheet-title').textContent=title;root.querySelector('#sheet-content').innerHTML=body;sheet.hidden=false;globalThis.lucide?.createIcons();root.querySelector('#sheet-close').focus();}
function action(a){if(['period','records'].includes(a)){state.page=a;render();persist();return;}if(a==='toggle'){state.visible=!state.visible;render();return;}if(a==='publish')openSheet('留下一页日常',`<div class="sheet-actions"><button data-action="write">${icon('notebook-pen')}写日记</button><button data-action="newAlbum">${icon('images')}建相册</button><button data-action="newMemory">${icon('calendar-heart')}记忆日</button><button data-action="bodyRecord">${icon('flower-2')}身体记录</button></div>`);else if(a==='write')openSheet('写日记','<label class="sheet-field">标题<input placeholder="给今天起个名字"></label><label class="sheet-field">正文<textarea placeholder="今天有什么想留下的?"></textarea></label><button class="rose-button" data-action="close">完成预览</button>');else if(a==='bodyRecord')openSheet(`10月${state.day}日 · 记录身体状态`,`<div class="sheet-actions">${[['flower-2','经量'],['smile','心情'],['thermometer','体温'],['notebook-pen','备注']].map(([i,n])=>`<button data-action="recordField">${icon(i)}${n}</button>`).join('')}</div>`);else if(a==='settings')openSheet('周期设置','<label class="sheet-field">最近一次开始日期<input type="date" value="2026-10-01"></label><label class="sheet-field">持续天数<input type="number" value="5"></label><label class="sheet-field">周期长度<input type="number" value="28"></label><button class="rose-button" data-action="close">完成预览</button>');else if(a==='story')openSheet('把日落装进口袋',scene()+'<p>沿着海边走了很久,回头的时候,刚好看见你在笑。</p><p>小满 · 2026年10月4日</p>');else if(a==='close'){sheet.hidden=true;}else{const titles={bind:'邀请另一半',memory:'第一次旅行纪念日',albums:'相册里的片段',album:'沿着海岸线',newAlbum:'创建相册',newMemory:'添加记忆日',refresh:'当前权限',relationship:'关系管理',appearance:'外观与通知',editProfile:'编辑资料',recordField:'当天状态',songs:'一起听的歌',checkin:'一起打卡',pet:'宠物养成',calendarHelp:'日历月份'};openSheet(titles[a]||'记录',a==='refresh'?'<p>对方仍关闭了可见。你自己的可见开关不会解除对方设置的限制。</p>':a==='relationship'?'<p>你和阿屿已绑定。</p><button class="outline-button" data-action="close">解绑情侣</button>':a==='bind'?'<label class="sheet-field">对方ID<input placeholder="输入另一半的ID"></label><button class="rose-button" data-action="close">完成预览</button>':'<p>此处展示交互与排版,正式页面继续使用现有接口数据。</p><button class="outline-button" data-action="close">返回</button>');}}
root.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page){state.page=b.dataset.page;render();persist();}else if(b.dataset.filter){state.filter=b.dataset.filter;render();}else if(b.dataset.day){state.day=Number(b.dataset.day);render();}else if(b.dataset.action)action(b.dataset.action);});picker.addEventListener('change',()=>{state.scenario=picker.value;state.visible=true;render();persist();});root.querySelector('#journal-back').addEventListener('click',()=>{state.page='home';render();persist();});root.querySelector('#sheet-close').addEventListener('click',()=>sheet.hidden=true);sheet.addEventListener('click',e=>{if(e.target===sheet)sheet.hidden=true;});root.addEventListener('keydown',e=>{if(e.key==='Escape')sheet.hidden=true;});window.addEventListener('openai:set_globals',e=>{const v=e.detail?.globals?.widgetState?.modelContent;if(v&&['home','records','period','profile'].includes(v.page)){state.page=v.page;if(['filled','empty','unbound','denied'].includes(v.scenario))state.scenario=v.scenario;render();}});render();if(globalThis.Tweak){const tweak=new Tweak({container:root.querySelector('.journal-phone'),onChange:render});tweak.addColorPicker(state,'accent',{label:'重点色',reference:'--j-rose'});tweak.addToggle(state,'paper',{label:'手账纸张倾斜'});}
})();
</script>
+5
View File
@@ -0,0 +1,5 @@
run/
config.yaml
web/admin/node_modules/
# web/admin/dist 会被 embed,构建后需存在;发布时用 make all 生成
*.log
+44
View File
@@ -0,0 +1,44 @@
# 我们past Go 服务构建
BIN := past-server
GOOS ?= linux
GOARCH ?= amd64
VERSION ?= $(shell date +%Y%m%d-%H%M)
.PHONY: all web build build-windows build-local run test diff clean
all: web build
## 构建 React 管理后台(产物 web/admin/dist 会被 embed)
web:
cd web/admin && npm ci --silent && npm run build
## 交叉编译 Linux 二进制(默认 linux/amd64;树莓派等用 GOARCH=arm64)
build:
CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o dist/$(BIN)-$(GOOS)-$(GOARCH) ./cmd/server
@ls -lh dist/
## Windows 部署包:past-server.exe + 配置模板 + 部署脚本 → dist/windows/
build-windows:
rm -rf dist/windows && mkdir -p dist/windows
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o dist/windows/past-server.exe ./cmd/server
cp config.example.yaml dist/windows/config.yaml
cp deploy/windows/*.ps1 deploy/windows/README.md dist/windows/
cd dist && rm -f past-windows-deploy.zip && zip -qr past-windows-deploy.zip windows
@ls -lh dist/windows/ dist/past-windows-deploy.zip
## 本机构建(开发用)
build-local:
go build -o run/$(BIN) ./cmd/server
run: build-local
./run/$(BIN) -config config.yaml
test:
go vet ./... && go test ./...
## PHP vs Go 响应对比(需先启动 dev 环境,见 ../dev/reset_dbs.sh)
diff:
cd ../dev && for s in scenarios/*.json; do ./reset_dbs.sh >/dev/null; python3 apidiff.py $$s | tail -1; done
clean:
rm -rf dist run/$(BIN) web/admin/dist
+57
View File
@@ -0,0 +1,57 @@
# 我们past后端(Go 版)
`backend/api/index.php` 的 Go 重写:gin + MySQL,管理后台(React)通过 `embed` 打进同一个二进制。
小程序与管理后台统一使用驼峰 `/api/接口名`(如 `/api/periodList`),请求参数与响应字段同样使用驼峰。业务值与鉴权延续现有约定。旧 PHP 风格入口继续兼容已发布客户端。
本次 Nginx 与部署说明见 [deploy/README.md](deploy/README.md)。
## 目录
```
cmd/server/ 入口
internal/config/ config.yaml 读取
internal/store/ MySQL 访问(行类型对齐 PDO)、schema.go = install.php 的移植
internal/httpapi/ gin 路由、Ctx($_GET/$_POST/$input/$_FILES 等价物)、鉴权
internal/handler/ 121 个 action,按模块拆分(user/couple/timeline/album/diary/songs/checkin/period/pet/misc/admin)
internal/upload/ handleUpload 等价物;文件落在 <data_dir>/uploads/
internal/wechat/ access_token / 内容安全 / 订阅消息 / jscode2session
internal/geo/ 腾讯位置服务
internal/notify/ cron_notify.php 的进程内定时版
web/admin/ React 管理后台(dist/ 被 embed)
```
## 运行
```bash
cp config.example.yaml config.yaml # 填写数据库 / 微信配置
go build -o past-server ./cmd/server
./past-server -config config.yaml
```
运行目录布局(`data_dir`,默认为二进制所在目录):
```
data_dir/
├── uploads/ 用户上传文件(从 PHP 的 backend/uploads 原样迁移)
└── data/ access_token.cache 等
```
URL 映射:
| URL | 说明 |
|---|---|
| `/api/接口名` | 小程序、管理后台、上传统一入口 |
| `/backend/api/index.php?action=…` | 旧客户端兼容入口 |
| `/backend/uploads/…` | 上传文件静态服务(对应 DB 中 `uploads/…` 路径) |
| `/backend/admin/` | 管理后台 SPA |
| `/healthz` | 健康检查 |
## 与 PHP 版的差异(有意为之)
见 `../dev/API_CONTRACT.md` 与对比测试 `../dev/apidiff.py`。已知的 PHP 原有 bug 处理:
- `*_add` 接口返回的 `id` 改为真实记录 id(PHP 返回的是 activity_log 的 id)。
- `pet_dynamics` 分页可用(PHP 因 `LIMIT '20'` 语法错误恒 500)。
- 宠物成就解锁可用(PHP 因 `pet_dynamics.user_id=0` 违反外键恒 500)。
- `logout` 会真正失效 token;登录不再踢掉其它设备的 token。
- 数据库迁移只在启动时执行一次(PHP 每次请求执行)。
+125
View File
@@ -0,0 +1,125 @@
// dbcheck runs only SELECT/SHOW statements. It never initializes or migrates a database.
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"
_ "github.com/go-sql-driver/mysql"
"past/internal/config"
)
type result struct {
Name string `json:"name"`
Rows []map[string]any `json:"rows,omitempty"`
Error string `json:"error,omitempty"`
}
func query(db *sql.DB, name, statement string) result {
r := result{Name: name}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, statement)
if err != nil {
r.Error = err.Error()
return r
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
r.Error = err.Error()
return r
}
for rows.Next() {
values := make([]any, len(columns))
ptrs := make([]any, len(columns))
for i := range values {
ptrs[i] = &values[i]
}
if err := rows.Scan(ptrs...); err != nil {
r.Error = err.Error()
return r
}
row := map[string]any{}
for i, key := range columns {
if b, ok := values[i].([]byte); ok {
row[key] = string(b)
} else {
row[key] = values[i]
}
}
r.Rows = append(r.Rows, row)
}
if err := rows.Err(); err != nil {
r.Error = err.Error()
}
return r
}
func run() error {
exe, err := os.Executable()
if err != nil {
return err
}
configPath := flag.String("config", filepath.Join(filepath.Dir(exe), "config.yaml"), "Existing server config.yaml; never upload its contents")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
db, err := sql.Open("mysql", cfg.DSN())
if err != nil {
return fmt.Errorf("cannot open database connection")
}
defer db.Close()
db.SetMaxOpenConns(1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("database connection failed; check the original config and MySQL service locally")
}
report := []result{
query(db, "database", "SELECT DATABASE() AS database_name, VERSION() AS version, NOW() AS database_time"),
query(db, "binary_log_settings", "SHOW VARIABLES WHERE Variable_name IN ('log_bin','log_bin_basename','binlog_format','binlog_row_image','datadir')"),
query(db, "binary_log_files", "SHOW BINARY LOGS"),
query(db, "relationship_status", "SELECT status,COUNT(*) AS records FROM couples GROUP BY status"),
query(db, "relationship_mismatches", "SELECT c.id,c.status,c.user1_id,c.user2_id,u1.couple_id AS user1_current_couple,u2.couple_id AS user2_current_couple FROM couples c LEFT JOIN users u1 ON u1.id=c.user1_id LEFT JOIN users u2 ON u2.id=c.user2_id WHERE c.status='active' AND (u1.id IS NULL OR u2.id IS NULL OR COALESCE(u1.couple_id,0)<>c.id OR COALESCE(u2.couple_id,0)<>c.id) ORDER BY c.id"),
query(db, "migration_marker", "SELECT `key`,`value` FROM settings WHERE `key`='account_records_v1'"),
}
for _, table := range []string{"users", "couples", "diaries", "albums", "photos", "timeline", "songs", "checkins", "period_records", "period_daily_records", "period_sex_records", "personal_period_records", "personal_period_daily_records", "personal_period_sex_records", "smoking_wallets", "smoking_entries"} {
report = append(report, query(db, "count_"+table, "SELECT COUNT(*) AS records FROM "+table))
}
for _, table := range []string{"diaries", "albums", "timeline", "songs", "checkins"} {
report = append(report, query(db, "space_"+table, "SELECT d.couple_id,c.status,COUNT(*) AS records,SUM(CASE WHEN d.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) AS matching_author,SUM(CASE WHEN d.created_by IS NULL OR d.created_by=0 THEN 1 ELSE 0 END) AS missing_author FROM "+table+" d LEFT JOIN couples c ON c.id=d.couple_id GROUP BY d.couple_id,c.status ORDER BY d.couple_id"))
}
f, err := os.CreateTemp(filepath.Dir(exe), "dbcheck-report-*.json")
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
encodeErr := enc.Encode(report)
closeErr := f.Close()
if encodeErr != nil {
return encodeErr
}
if closeErr != nil {
return closeErr
}
fmt.Println("Read-only check complete. No migration or data changes performed.")
fmt.Println("Report:", f.Name())
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+156
View File
@@ -0,0 +1,156 @@
// 我们past后端(Go 版):API 与 PHP 版 backend/api/index.php 完全兼容。
package main
import (
"context"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
_ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用
"past/internal/config"
"past/internal/geo"
"past/internal/handler"
"past/internal/httpapi"
"past/internal/notify"
"past/internal/store"
"past/internal/upload"
"past/internal/wechat"
"past/web"
)
// version 由构建时 -ldflags 注入
var version = "dev"
func main() {
cfgPath := flag.String("config", "config.yaml", "配置文件路径")
showVer := flag.Bool("version", false, "显示版本")
svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop")
flag.Parse()
if *showVer {
fmt.Println("past-server", version)
return
}
if *svcCmd != "" {
if err := serviceControl(*svcCmd, *cfgPath); err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
os.Exit(1)
}
return
}
// 作为 Windows 服务运行时,工作目录是 System32:配置文件相对路径按可执行文件所在目录解析
if !filepath.IsAbs(*cfgPath) {
if exe, err := os.Executable(); err == nil {
if _, err := os.Stat(*cfgPath); err != nil {
*cfgPath = filepath.Join(filepath.Dir(exe), *cfgPath)
}
}
}
if runAsServiceIfNeeded(func(stop <-chan struct{}) { serve(*cfgPath, stop) }) {
return
}
// 控制台模式:Ctrl+C / SIGTERM 触发优雅退出
stop := make(chan struct{})
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
close(stop)
}()
serve(*cfgPath, stop)
}
// serve 启动全部组件并阻塞到 stop 关闭
func serve(cfgPath string, stop <-chan struct{}) {
// 与 PHP date_default_timezone_set('Asia/Shanghai') 一致
if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil {
time.Local = loc
}
cfg, err := config.Load(cfgPath)
if err != nil {
slog.Error("配置加载失败", "err", err)
os.Exit(1)
}
// 确保必要目录存在(对应 config.php 中的 $dirs)
for _, d := range []string{"data", "data/tmp", "uploads", "uploads/avatars", "uploads/couples", "uploads/users"} {
os.MkdirAll(filepath.Join(cfg.DataDir, d), 0755)
}
// 日志:stdout + data/go-server.log(Windows 服务没有控制台,文件日志是主要途径)
logOut := io.Writer(os.Stdout)
if f, err := os.OpenFile(filepath.Join(cfg.LogDir(), "go-server.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
logOut = io.MultiWriter(os.Stdout, f)
defer f.Close()
}
slog.SetDefault(slog.New(slog.NewTextHandler(logOut, &slog.HandlerOptions{Level: slog.LevelInfo})))
db, err := store.Open(cfg.DSN())
if err != nil {
slog.Error("数据库连接失败", "err", err)
os.Exit(1)
}
db.UploadDir = cfg.UploadDir()
db.NumericAsString = cfg.PHPCompat < "8.1"
if err := db.InitSchema(); err != nil {
slog.Error("数据库初始化失败", "err", err)
os.Exit(1)
}
if err := db.MigrateSmokingRecords(); err != nil {
slog.Error("抽烟库存迁移失败", "err", err)
os.Exit(1)
}
if err := db.AdvanceUnbindings(); err != nil {
slog.Error("解绑状态处理失败", "err", err)
os.Exit(1)
}
go db.RunUnbindingWorker()
wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, cfg.DataDir, db)
app := &httpapi.App{
Cfg: cfg,
DB: db,
Wechat: wx,
Geo: geo.New(cfg.TencentMapKey),
Admin: httpapi.NewAdminSessions(),
}
uploader := &upload.Uploader{Dir: cfg.UploadDir(), MaxSize: cfg.MaxUploadMB * 1024 * 1024, Checker: wx}
router := httpapi.NewRouter(app, web.AdminFS())
handler.Register(router, uploader)
slog.Info("已注册 action", "count", len(router.Actions()))
if cfg.NotifyIntervalMin > 0 {
go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute)
}
srv := &http.Server{
Addr: cfg.Listen,
Handler: router.Engine(cfg.Debug),
ReadHeaderTimeout: 30 * time.Second,
ReadTimeout: 180 * time.Second, // 大文件上传
WriteTimeout: 180 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
slog.Info("服务启动", "version", version, "listen", cfg.Listen, "data_dir", cfg.DataDir)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("服务异常退出", "err", err)
os.Exit(1)
}
}()
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
srv.Shutdown(ctx)
slog.Info("服务已停止")
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !windows
package main
import "fmt"
func runAsServiceIfNeeded(run func(stop <-chan struct{})) bool { return false }
func serviceControl(cmd, cfgPath string) error {
return fmt.Errorf("-service 仅在 Windows 下可用(Linux 请使用 systemd)")
}
+120
View File
@@ -0,0 +1,120 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"time"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
const svcName = "past-server"
// runAsServiceIfNeeded 在被 Windows 服务管理器启动时接管生命周期;返回 true 表示已作为服务运行完毕。
func runAsServiceIfNeeded(run func(stop <-chan struct{})) bool {
isSvc, err := svc.IsWindowsService()
if err != nil || !isSvc {
return false
}
_ = svc.Run(svcName, &svcHandler{run: run})
return true
}
type svcHandler struct{ run func(stop <-chan struct{}) }
func (h *svcHandler) Execute(_ []string, r <-chan svc.ChangeRequest, s chan<- svc.Status) (bool, uint32) {
s <- svc.Status{State: svc.StartPending}
stop := make(chan struct{})
done := make(chan struct{})
go func() { h.run(stop); close(done) }()
s <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
for {
select {
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
s <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
s <- svc.Status{State: svc.StopPending}
close(stop)
select {
case <-done:
case <-time.After(15 * time.Second):
}
return false, 0
}
case <-done:
return false, 0
}
}
}
// serviceControl 处理 -service install|uninstall|start|stop(需要管理员权限)
func serviceControl(cmd, cfgPath string) error {
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("连接服务管理器失败(请用管理员身份运行): %w", err)
}
defer m.Disconnect()
switch cmd {
case "install":
exe, err := os.Executable()
if err != nil {
return err
}
abs, _ := filepath.Abs(cfgPath)
if s, err := m.OpenService(svcName); err == nil {
s.Close()
return fmt.Errorf("服务 %s 已存在(先执行 -service uninstall)", svcName)
}
s, err := m.CreateService(svcName, exe, mgr.Config{
DisplayName: "我们past Go 服务 (past-server)",
Description: "past.sundynix.cn 后端 API 与管理后台",
StartType: mgr.StartAutomatic,
}, "-config", abs)
if err != nil {
return err
}
defer s.Close()
// 异常退出自动重启
_ = s.SetRecoveryActions([]mgr.RecoveryAction{
{Type: mgr.ServiceRestart, Delay: 3 * time.Second},
{Type: mgr.ServiceRestart, Delay: 3 * time.Second},
{Type: mgr.ServiceRestart, Delay: 10 * time.Second},
}, 86400)
if err := s.Start(); err != nil {
return fmt.Errorf("已安装但启动失败: %w", err)
}
fmt.Println("服务已安装并启动:", svcName, "配置:", abs)
case "uninstall":
s, err := m.OpenService(svcName)
if err != nil {
return fmt.Errorf("服务不存在: %w", err)
}
defer s.Close()
_, _ = s.Control(svc.Stop)
time.Sleep(2 * time.Second)
if err := s.Delete(); err != nil {
return err
}
fmt.Println("服务已卸载:", svcName)
case "start", "stop":
s, err := m.OpenService(svcName)
if err != nil {
return fmt.Errorf("服务不存在: %w", err)
}
defer s.Close()
if cmd == "start" {
return s.Start()
}
_, err = s.Control(svc.Stop)
return err
default:
return fmt.Errorf("未知 -service 命令: %s(install|uninstall|start|stop)", cmd)
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
# 我们past Go 服务配置(复制为 config.yaml 后填写真实值)
listen: ":8080"
# 运行数据目录(uploads/ 与 data/ 在其下),留空则为可执行文件所在目录
data_dir: ""
db:
host: 127.0.0.1
port: 3306
name: past
user: past
pass: your_db_password
wechat:
appid: your_appid
secret: your_secret
notify_template_id: tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8
tencent_map_key: ""
max_upload_mb: 50
# 定时补发通知间隔(分钟),0 关闭
notify_interval_min: 5
+60
View File
@@ -0,0 +1,60 @@
# 20261008 原权限兼容与抽烟功能
## 当前行为
- 日记、相册、记忆日、歌曲和普通打卡恢复原版按情侣成员作者查询;历史 couple_id 不再成为新增过滤条件。未绑定展示方式恢复原版。
- 姨姨继续使用 period_records、period_daily_records、period_sex_records,恢复原版共享记录、来源选择和可见开关。启动不复制或切换姨姨表。此前创建的 personal_period_* 表保留,但本版不读写。
- 照片响应保持原始 uploads/... 路径,不签名、不改成 /backend/uploads/...;上传静态文件恢复原访问方式,兼容正式版小程序。
- 抽烟使用独立的 smoking_wallets 和 smoking_entries;消耗一支扣一支、买烟和别人递烟加库存、撤销操作保持事务和幂等。库存随账号保留。
- 72 小时解绑冷静期继续保留:双方同意后开始计时,期间可取消,到期解除绑定且保留记录。其他数据权限按原版运行。
## 部署
1. 在宝塔备份当前数据库和 uploads,保存原可运行 exe;停止现有 Go 服务。
2. 仅替换 past-server.exe,保留现有 config.yaml、data_dir、uploads、data 和数据库。无需重新配置 Nginx,已正常工作的 uploads 转发继续使用。
3. 启动时补齐抽烟表和冷静期字段;仅迁移旧抽烟库存与流水。smoking_records_v1=done 后不会重复;已经完成 account_records_v1 的环境也不会重新导入库存。不存在姨姨数据迁移。
4. 先验证正式版日记、相册、照片、记忆日、歌曲、普通打卡和姨姨记录,再用新版小程序验证抽烟。
5. 新版小程序的姨姨页面已同步恢复共享方式;抽烟需要上传新版小程序才能显示。
Go 全套和前端回归在本机执行;尚未在生产数据库或微信真机运行本版。历史 binlog 和检查报告显示原记录在库,不应通过清表或倒放日志部署本版。
以下为 20261005 历史说明,当前部署以以上步骤为准。
---
# 20261005 UI 精修与驼峰 API 更新
## 部署顺序
1. 停止现有服务,备份原程序;用新 Windows AMD64 `past-server.exe` 替换程序。保留当前 `config.yaml`、`uploads`、`data` 和数据库。示例配置仅供参考,不要覆盖现有配置。
2. 启动新程序。本次没有新增数据库迁移,保留此前生理期共享功能的数据结构。
3. 如果上一版 `/api`、`/api/` 转发已配置并能访问,这次无需再改 Nginx:驼峰路径继续走同一段转发。附带配置已按你提供的 Nginx 使用 `127.0.0.1:8081`;保留现有 `/backend/` 与 uploads 缓存配置。
4. 检查并重新加载 Nginx,已有配置不变时可以跳过。若调整配置,在你当前 Nginx 机器上检查后重新加载。
5. 浏览器访问 `https://past.sundynix.cn/api` 应返回 API 名称与版本。未登录访问 `/api/coupleInfo` 应返回 401 JSON,不能是 Nginx HTML 404。
6. 管理后台仍从 `/backend/admin/` 进入,新后台已嵌入程序,刷新页面、重新登录后确认 Network 请求为 `/api/adminStats` 等驼峰路径。
7. 微信开发者工具打开包里的 `weapp`;request/uploadFile 合法域名仍为 `https://past.sundynix.cn`。重新编译、真机预览,再上传小程序新版本。
## 接口规则
- 请求地址:`/api/接口名`,例如 `/api/periodList`、`/api/diaryList?page=1&pageSize=20`。
- 小程序、管理后台、上传统一使用新路径;不再附加 GET `_t`。
- 例:`POST /api/periodUpdate`,JSON `{ "id": "314", "cycleLength": 25, "periodLength": 5, "startDate": "2026-09-27", "endDate": "2026-10-01" }`。
- 路径、查询参数、JSON 请求与响应字段统一 lowerCamelCase,例如 `cycleLength`、`periodVisible`、`canAccess`。Multipart 表单字段同样为驼峰。字段值、请求方法和鉴权延续原业务约定。
- 后端及两个客户端在网络边界转换字段名,内部数据库和页面数据模型保持既有结构。旧 PHP 入口与带下划线的旧路径继续兼容。API 响应 `Cache-Control: no-store`;后台请求使用 `cache: no-store`。
- Bearer、上传 token 与后台同源 Cookie 仍有效。上传继续携带兼容 Android 的 token/action 表单项。
- 原 PHP 风格入口继续兼容已发布旧小程序。媒体与后台静态地址仍在 `/backend/` 下。
## 小程序验证
- Android 与 iPhone:标题与微信胶囊不重叠,首屏和底部内容不被遮挡。
- 首页:未绑定显示注册天数和邀请入口,绑定后显示在一起天数;解绑后首页、我的立即按接口更新。
- 三个底部入口:首页、记录、我的;详情返回与发布、编辑流程正常。
- 日记和相册:首次加载、空列表、网络失败分别展示;创建相册与上传照片正常。
- 姨姨:对方可见单独操作;关闭自己的开关不能把自己锁住,权限由 API 判断。双方都有绑定前数据时仍选择唯一记录。
- 改周期、持续天数、开始和结束日期保存后,重新进入展示服务端最新值。
本地已检查 WXML/WXSS 编译、JS 语法、请求/上传 URL 与导航尺寸、Go 测试与管理后台构建;未替代真实微信设备和线上数据库联调。
## 本次 UI 精修
小程序首页聚焦双人天数、四个入口和最近记录;记录页分组;日记、相册使用标题旁操作按钮;姨姨选中日期后展开当天操作;个人中心与设置分组。管理后台统一侧栏、页头、统计卡、列表、表格、弹层与登录页,沿用现有组件库。
@@ -0,0 +1,42 @@
-- 只读排查:在原业务数据库中执行。不要修改关系、记录归属或迁移标记。
SELECT DATABASE() AS current_database;
SELECT 'users' AS table_name, COUNT(*) AS records FROM users
UNION ALL SELECT 'couples', COUNT(*) FROM couples
UNION ALL SELECT 'diaries', COUNT(*) FROM diaries
UNION ALL SELECT 'albums', COUNT(*) FROM albums
UNION ALL SELECT 'photos', COUNT(*) FROM photos
UNION ALL SELECT 'timeline', COUNT(*) FROM timeline
UNION ALL SELECT 'checkins', COUNT(*) FROM checkins
UNION ALL SELECT 'songs', COUNT(*) FROM songs
UNION ALL SELECT 'period_records', COUNT(*) FROM period_records;
-- 关系一致性:只显示内部 ID,不输出手机号、密码、token 等信息。
SELECT c.id AS couple_id, c.status, c.user1_id, c.user2_id,
u1.couple_id AS user1_current_couple, u2.couple_id AS user2_current_couple
FROM couples c
LEFT JOIN users u1 ON u1.id = c.user1_id
LEFT JOIN users u2 ON u2.id = c.user2_id
ORDER BY c.id;
-- 按历史空间检查记录是否还在,以及新的作者过滤是否排除了旧记录。
SELECT 'diaries' AS table_name, d.couple_id, c.status,
COUNT(*) AS records,
SUM(CASE WHEN d.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) AS matching_author
FROM diaries d LEFT JOIN couples c ON c.id=d.couple_id
GROUP BY d.couple_id,c.status
UNION ALL
SELECT 'albums', a.couple_id, c.status, COUNT(*),
SUM(CASE WHEN a.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END)
FROM albums a LEFT JOIN couples c ON c.id=a.couple_id
GROUP BY a.couple_id,c.status
UNION ALL
SELECT 'timeline', t.couple_id, c.status, COUNT(*),
SUM(CASE WHEN t.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END)
FROM timeline t LEFT JOIN couples c ON c.id=t.couple_id
GROUP BY t.couple_id,c.status
UNION ALL
SELECT 'checkins', k.couple_id, c.status, COUNT(*),
SUM(CASE WHEN k.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END)
FROM checkins k LEFT JOIN couples c ON c.id=k.couple_id
GROUP BY k.couple_id,c.status;
+39
View File
@@ -0,0 +1,39 @@
# 放在 past.sundynix.cn 现有 HTTPS server 块里。
# proxy_pass 末尾不要加 /,需要保留 /api/接口名。
location = /api {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /api/ {
client_max_body_size 50m;
proxy_read_timeout 120s;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 如果原来没有把 /backend/ 转给 Go,再加入下面这一段。
# 已有同名 location 时修改原配置,不要重复添加。
# location ^~ /backend/ {
# client_max_body_size 50m;
# proxy_read_timeout 120s;
# proxy_pass http://127.0.0.1:8081;
# proxy_set_header Host $host;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# 上传文件继续转发 Go;本版恢复原静态文件访问,不需要签名。
location ^~ /backend/uploads/ {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "private, no-store" always;
}
# 避免不存在的旧路径被其他静态规则处理。
location ^~ /uploads/ { return 403; }
+42
View File
@@ -0,0 +1,42 @@
module past
go 1.26.4
require (
github.com/gin-gonic/gin v1.12.0
github.com/go-sql-driver/mysql v1.10.1
golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0
gopkg.in/yaml.v3 v3.0.1
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+101
View File
@@ -0,0 +1,101 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+90
View File
@@ -0,0 +1,90 @@
// Package config 读取服务配置(对应 PHP 的 config.php + config.credentials.php)。
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
Listen string `yaml:"listen"` // 监听地址,如 ":8080"
Debug bool `yaml:"debug"` // gin debug 模式(打印路由等)
// DataDir 运行数据目录:其下 uploads/ 存用户上传文件,data/ 存日志与缓存。
// 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。
DataDir string `yaml:"data_dir"`
DB struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Name string `yaml:"name"`
User string `yaml:"user"`
Pass string `yaml:"pass"`
} `yaml:"db"`
Wechat struct {
AppID string `yaml:"appid"`
Secret string `yaml:"secret"`
// 订阅消息模板 ID(对应 WECHAT_SUBSCRIBE_TEMPLATES['notify'])
NotifyTemplateID string `yaml:"notify_template_id"`
} `yaml:"wechat"`
TencentMapKey string `yaml:"tencent_map_key"`
// PHPCompat 对齐的 PHP 版本:"8.0"(默认,数据库数值按字符串返回)或 "8.1"(原生类型)
PHPCompat string `yaml:"php_compat"`
// MaxUploadMB 单文件上传上限(PHP: MAX_UPLOAD_SIZE = 50MB)
MaxUploadMB int64 `yaml:"max_upload_mb"`
// Notify 定时补发通知(对应 cron_notify.php)的执行间隔(分钟),0 表示关闭
NotifyIntervalMin int `yaml:"notify_interval_min"`
}
func Load(path string) (*Config, error) {
c := &Config{}
c.Listen = ":8080"
c.DB.Host = "127.0.0.1"
c.DB.Port = 3306
c.DB.Name = "past"
c.DB.User = "past"
c.MaxUploadMB = 50
c.NotifyIntervalMin = 5
c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8"
c.PHPCompat = "8.0"
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取配置文件 %s: %w", path, err)
}
if err := yaml.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("解析配置文件 %s: %w", path, err)
}
if c.DataDir == "" {
exe, err := os.Executable()
if err != nil {
return nil, err
}
c.DataDir = filepath.Dir(exe)
}
abs, err := filepath.Abs(c.DataDir)
if err != nil {
return nil, err
}
c.DataDir = abs
return c, nil
}
func (c *Config) UploadDir() string { return filepath.Join(c.DataDir, "uploads") }
func (c *Config) LogDir() string { return filepath.Join(c.DataDir, "data") }
func (c *Config) DSN() string {
// interpolateParams=true:统一走文本协议,所有列以字节返回,由 store 按列类型转换(对齐 PDO 行为)
// time_zone 会在驱动建立的每个 MySQL 连接上设置会话时区,保证 SQL NOW()/CURRENT_TIMESTAMP
// 与 Go 进程的 Asia/Shanghai 一致。使用固定 +08:00 不依赖 MySQL 时区表是否安装。
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=false&interpolateParams=true&loc=Local&time_zone=%%27%%2B08%%3A00%%27&multiStatements=false",
c.DB.User, c.DB.Pass, c.DB.Host, c.DB.Port, c.DB.Name)
}
+27
View File
@@ -0,0 +1,27 @@
package config
import (
"strings"
"testing"
"github.com/go-sql-driver/mysql"
)
func TestDSNSetsShanghaiOffsetForEveryConnection(t *testing.T) {
c := &Config{}
c.DB.Host = "127.0.0.1"
c.DB.Port = 3306
c.DB.Name = "past"
c.DB.User = "past"
got := c.DSN()
if !strings.Contains(got, "time_zone=%27%2B08%3A00%27") {
t.Fatalf("DSN does not set MySQL session time zone: %s", got)
}
parsed, err := mysql.ParseDSN(got)
if err != nil {
t.Fatalf("invalid DSN: %v", err)
}
if parsed.Params["time_zone"] != "'+08:00'" {
t.Fatalf("unexpected decoded MySQL time_zone: %q", parsed.Params["time_zone"])
}
}
+152
View File
@@ -0,0 +1,152 @@
// Package geo 对应 index.php 顶部的腾讯位置服务函数:逆地理编码、IP 定位、球面距离。
package geo
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
"past/internal/store"
)
type Client struct {
Key string
http *http.Client
}
func New(key string) *Client {
return &Client{Key: key, http: &http.Client{Timeout: 5 * time.Second}}
}
func (c *Client) get(u string) map[string]any {
resp, err := c.http.Get(u)
if err != nil {
return nil
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if len(b) == 0 {
return nil
}
var data map[string]any
if json.Unmarshal(b, &data) != nil {
return nil
}
return data
}
func statusOK(data map[string]any) bool {
if data == nil {
return false
}
st, ok := data["status"]
// PHP 用 === 0 判断:JSON 数字 0 解码为 float 0
return ok && store.ToFloat(st) == 0 && st != nil
}
func sub(m map[string]any, key string) map[string]any {
if m == nil {
return nil
}
v, _ := m[key].(map[string]any)
return v
}
// ReverseGeocode 经纬度(gcj02)→ "省 市 区县";未配置 key 或失败返回空串。
func (c *Client) ReverseGeocode(lat, lng float64) string {
if c.Key == "" || lat == 0 || lng == 0 {
return ""
}
u := fmt.Sprintf("https://apis.map.qq.com/ws/geocoder/v1/?location=%s,%s&key=%s&coord_type=5",
store.ToStr(lat), store.ToStr(lng), url.QueryEscape(c.Key))
data := c.get(u)
if !statusOK(data) {
return ""
}
result := sub(data, "result")
comp := sub(result, "address_component")
if comp == nil {
return ""
}
var parts []string
prov, city, dist := store.ToStr(comp["province"]), store.ToStr(comp["city"]), store.ToStr(comp["district"])
if prov != "" {
parts = append(parts, prov)
}
if city != "" && city != prov {
parts = append(parts, city)
}
if dist != "" {
parts = append(parts, dist)
}
if len(parts) > 0 {
return strings.Join(parts, " ")
}
return store.ToStr(result["address"])
}
// IPGeolocate IP → "市 区县 街道"(不含省份);失败返回空串。
func (c *Client) IPGeolocate(ip string) string {
if c.Key == "" || ip == "" || ip == "127.0.0.1" || ip == "::1" {
return ""
}
data := c.get("https://apis.map.qq.com/ws/location/v1/ip?ip=" + url.QueryEscape(ip) + "&key=" + url.QueryEscape(c.Key))
if !statusOK(data) {
return ""
}
result := sub(data, "result")
ad := sub(result, "ad_info")
if ad == nil {
return ""
}
if loc := sub(result, "location"); loc != nil && store.Truthy(loc["lat"]) && store.Truthy(loc["lng"]) {
geo := c.get(fmt.Sprintf("https://apis.map.qq.com/ws/geocoder/v1/?location=%s,%s&key=%s&coord_type=5",
store.ToStr(loc["lat"]), store.ToStr(loc["lng"]), url.QueryEscape(c.Key)))
if statusOK(geo) {
if comp := sub(sub(geo, "result"), "address_component"); comp != nil {
var parts []string
prov, city, dist, street := store.ToStr(comp["province"]), store.ToStr(comp["city"]), store.ToStr(comp["district"]), store.ToStr(comp["street"])
if city != "" && city != prov {
parts = append(parts, city)
}
if dist != "" {
parts = append(parts, dist)
}
if street != "" {
parts = append(parts, street)
}
if len(parts) > 0 {
return strings.Join(parts, " ")
}
}
}
}
var parts []string
prov, city, dist := store.ToStr(ad["province"]), store.ToStr(ad["city"]), store.ToStr(ad["district"])
if city != "" && city != prov {
parts = append(parts, city)
}
if dist != "" {
parts = append(parts, dist)
}
if len(parts) > 0 {
return strings.Join(parts, " ")
}
return store.ToStr(sub(result, "location")["city"])
}
// Haversine 两点球面距离(公里,保留 1 位小数)
func Haversine(lat1, lng1, lat2, lng2 float64) float64 {
const r = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180
dLng := (lng2 - lng1) * math.Pi / 180
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLng/2)*math.Sin(dLng/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return math.Round(r*c*10) / 10
}
+819
View File
@@ -0,0 +1,819 @@
package handler
import (
"fmt"
"math"
"regexp"
"strings"
"past/internal/httpapi"
"past/internal/store"
)
func registerAdmin(r *httpapi.Router) {
r.Handle("admin_login", adminLogin)
r.Handle("admin_check_auth", adminCheckAuth)
r.Handle("admin_logout", adminLogout)
r.Handle("admin_stats", adminStats)
r.Handle("admin_stats_detail", adminStatsDetail)
r.Handle("admin_couples", adminCouples)
r.Handle("admin_couple_detail", adminCoupleDetail)
r.Handle("admin_pet_detail", adminPetDetail)
r.Handle("admin_pets", adminPets)
r.Handle("admin_users", adminUsers)
r.Handle("admin_fix_last_login", adminFixLastLogin)
r.Handle("admin_clean_locations", adminCleanLocations)
r.Handle("admin_user_detail", adminUserDetail)
r.Handle("admin_change_password", adminChangePassword)
r.Handle("admin_change_username", adminChangeUsername)
r.Handle("admin_update_user", adminUpdateUser)
r.Handle("admin_delete_user", adminDeleteUser)
r.Handle("admin_unbind_requests", adminUnbindRequests)
r.Handle("admin_force_unbind", adminForceUnbind)
r.Handle("admin_restore_couple", adminRestoreCouple)
r.Handle("admin_delete_couple_data", adminDeleteCoupleData)
r.Handle("admin_delete_content", adminDeleteContent)
r.Handle("admin_contents", adminContents)
r.Handle("admin_reset_user_password", adminResetUserPassword)
r.Handle("admin_activity_log", adminActivityLog)
r.Handle("admin_subscribe_logs", adminSubscribeLogs)
r.Handle("admin_get_global_bg", adminGetGlobalBg)
r.Handle("admin_set_global_bg", adminSetGlobalBg)
r.Handle("admin_clear_global_bg", adminClearGlobalBg)
r.Handle("admin_couple_bg_list", adminCoupleBgList)
r.Handle("admin_clear_couple_bg", adminClearCoupleBg)
}
func adminLogin(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
admin, err := c.DB.QueryOne("SELECT * FROM admins WHERE username = ?", c.InTrim("username"))
if err != nil {
return err
}
if admin != nil && verifyPassword(c.InStr("password"), admin.Str("password")) {
c.AdminLogin(admin.Int("id"), admin.Str("username"))
return c.OK(M{"success": true, "username": admin["username"]})
}
return c.Err(401, "用户名或密码错误")
}
func adminCheckAuth(c *httpapi.Ctx) error {
return c.OK(M{"logged_in": c.AdminSession() != nil})
}
func adminLogout(c *httpapi.Ctx) error {
c.AdminLogout()
return c.OK(M{"success": true})
}
// tableCount 对应 $db->query("SELECT COUNT(*) as c FROM t")->fetch()['c'](原样,未 intval)
func tableCount(c *httpapi.Ctx, table string) any {
return countRaw(c, "c", "SELECT COUNT(*) as c FROM "+table)
}
func adminStats(c *httpapi.Ctx) error {
return c.OK(M{
"total_users": tableCount(c, "users"), "total_couples": tableCount(c, "couples"),
"total_timelines": tableCount(c, "timeline"), "total_diaries": tableCount(c, "diaries"),
"total_albums": tableCount(c, "albums"), "total_photos": tableCount(c, "photos"),
"total_songs": tableCount(c, "songs"), "total_checkins": tableCount(c, "checkins"),
"total_pets": tableCount(c, "pets"), "total_pet_achievements": tableCount(c, "pet_achievements"),
})
}
var ymdRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
func adminStatsDetail(c *httpapi.Ctx) error {
date := c.Q("date")
if !c.QHas("date") {
date = today()
}
if !ymdRe.MatchString(date) {
return c.Err(400, "日期格式错误,请使用 YYYY-MM-DD")
}
month := date[:7]
dateCount := func(table string) int64 {
return countC(c, "SELECT COUNT(*) as c FROM `"+table+"` WHERE DATE(created_at) = ?", date)
}
contents := M{}
var total int64
for _, t := range []string{"timeline", "diaries", "albums", "photos", "songs", "checkins", "checkin_logs"} {
n := dateCount(t)
contents[t] = n
total += n
}
return c.OK(M{
"date": date,
"today_registers": dateCount("users"),
"month_registers": countC(c, "SELECT COUNT(*) as c FROM users WHERE DATE_FORMAT(created_at, '%Y-%m') = ?", month),
"date_registers": dateCount("users"),
"date_couples": dateCount("couples"),
"date_contents": contents,
"date_contents_total": total,
"total_users": tableCount(c, "users"),
"total_couples": tableCount(c, "couples"),
})
}
// paged 后台列表分页包装:query 参数 page / page_size(默认 10,最大 100),返回 {items,total,page,page_size,total_pages}
func paged(c *httpapi.Ctx, all []store.Row) M {
page, pageSize, offset := pageParams(c, 10, 100)
total := int64(len(all))
end := offset + pageSize
if offset > total {
offset = total
}
if end > total {
end = total
}
return M{
"items": rows(all[offset:end]), "total": total, "page": page, "page_size": pageSize,
"total_pages": int64(math.Ceil(float64(total) / float64(pageSize))),
}
}
func adminCouples(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT c.*, c.couple_space_id, u1.nickname as user1_nick, u1.username as user1_name, u1.gender as user1_gender, u1.age as user1_age, u1.birthday as user1_birthday, u2.nickname as user2_nick, u2.username as user2_name, u2.gender as user2_gender, u2.age as user2_age, u2.birthday as user2_birthday, (SELECT COUNT(*) FROM timeline WHERE couple_id = c.id) as timeline_count, (SELECT COUNT(*) FROM diaries WHERE couple_id = c.id) as diary_count, (SELECT COUNT(*) FROM albums WHERE couple_id = c.id) as album_count, (SELECT COUNT(*) FROM songs WHERE couple_id = c.id) as songs_count, (SELECT COUNT(*) FROM checkins WHERE couple_id = c.id) as checkin_count FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id ORDER BY c.created_at DESC")
if err != nil {
return err
}
return c.OK(paged(c, rs))
}
// petFullDetail 宠物详情各子表(admin_couple_detail / admin_pet_detail 共用)
func petFullDetail(c *httpapi.Ctx, cid int64) (dailies, achievements, dynamics, items []store.Row, err error) {
if dailies, err = c.DB.Query("SELECT * FROM pet_dailies WHERE couple_id = ? ORDER BY date DESC LIMIT 60", cid); err != nil {
return
}
if achievements, err = c.DB.Query("SELECT pa.* FROM pet_achievements pa WHERE pa.couple_id = ? ORDER BY pa.unlocked_at DESC", cid); err != nil {
return
}
if dynamics, err = c.DB.Query("SELECT * FROM pet_dynamics WHERE couple_id = ? ORDER BY created_at DESC LIMIT 30", cid); err != nil {
return
}
items, err = c.DB.Query("SELECT * FROM pet_items WHERE couple_id = ? ORDER BY created_at DESC", cid)
return
}
// rowOrFalse 对应 PDO fetch() 无结果返回 false
func rowOrFalse(r store.Row) any {
if r == nil {
return false
}
return r
}
func adminCoupleDetail(c *httpapi.Ctx) error {
var cid int64
if c.Method == "GET" {
cid = c.QInt("couple_id")
} else {
cid = c.InInt("couple_id")
}
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
data := M{}
users, err := c.DB.Query("SELECT id, user_id, username, nickname, avatar FROM users WHERE couple_id = ?", cid)
if err != nil {
return err
}
data["users"] = rows(users)
data["user1_id"], data["user2_id"] = int64(0), int64(0)
if len(users) > 0 {
data["user1_id"] = users[0]["id"]
}
if len(users) > 1 {
data["user2_id"] = users[1]["id"]
}
q := func(key, sql string) error {
rs, err := c.DB.Query(sql, cid)
if err != nil {
return err
}
data[key] = rows(rs)
return nil
}
if err := q("timeline", "SELECT * FROM timeline WHERE couple_id = ? ORDER BY event_date DESC"); err != nil {
return err
}
if err := q("diaries", "SELECT * FROM diaries WHERE couple_id = ? ORDER BY diary_date DESC"); err != nil {
return err
}
if err := q("albums", "SELECT * FROM albums WHERE couple_id = ?"); err != nil {
return err
}
data["photos"] = []store.Row{}
if albums := data["albums"].([]store.Row); len(albums) > 0 {
ids := make([]any, 0, len(albums))
for _, a := range albums {
ids = append(ids, a["id"])
}
photos, err := c.DB.Query("SELECT * FROM photos WHERE album_id IN ("+placeholders(len(ids))+") ORDER BY album_id, sort_order ASC, created_at DESC", ids...)
if err != nil {
return err
}
data["photos"] = rows(photos)
}
if err := q("songs", "SELECT * FROM songs WHERE couple_id = ? ORDER BY created_at DESC"); err != nil {
return err
}
if err := q("checkins", "SELECT * FROM checkins WHERE couple_id = ? ORDER BY created_at DESC"); err != nil {
return err
}
pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", cid)
if err != nil {
return err
}
if pet != nil {
pet["achievement_count"] = countC(c, "SELECT COUNT(*) as c FROM pet_achievements WHERE couple_id = ?", cid)
pet["daily_count"] = countC(c, "SELECT COUNT(*) as c FROM pet_dailies WHERE couple_id = ?", cid)
dailies, achs, dyns, items, err := petFullDetail(c, cid)
if err != nil {
return err
}
pet["dailies"], pet["achievements"], pet["dynamics"], pet["items"] = rows(dailies), rows(achs), rows(dyns), rows(items)
}
data["pet"] = rowOrFalse(pet)
return c.OK(data)
}
func adminPetDetail(c *httpapi.Ctx) error {
cid := c.QInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", cid)
if err != nil {
return c.Err(500, "服务器错误: "+err.Error())
}
dailies, achs, dyns, items, err := petFullDetail(c, cid)
if err != nil {
return c.Err(500, "服务器错误: "+err.Error())
}
return c.OK(M{"pet": rowOrFalse(pet), "dailies": rows(dailies), "achievements": rows(achs), "dynamics": rows(dyns), "items": rows(items)})
}
func adminPets(c *httpapi.Ctx) error {
var where []string
var params []any
if store.Truthy(c.Q("breed")) {
where = append(where, "p.breed = ?")
params = append(params, c.Q("breed"))
}
if store.Truthy(c.Q("stage")) {
where = append(where, "p.stage = ?")
params = append(params, c.QInt("stage"))
}
whereSQL := ""
if len(where) > 0 {
whereSQL = "WHERE " + strings.Join(where, " AND ")
}
rs, err := c.DB.Query(`SELECT p.*, u1.nickname as user1_name, u2.nickname as user2_name FROM pets p
LEFT JOIN users u1 ON p.created_by = u1.id
LEFT JOIN couples c ON p.couple_id = c.id
LEFT JOIN users u2 ON (CASE WHEN c.user1_id = p.created_by THEN c.user2_id ELSE c.user1_id END) = u2.id
`+whereSQL+` ORDER BY p.level DESC, p.updated_at DESC`, params...)
if err != nil {
return err
}
return c.OK(paged(c, rs))
}
func adminUsers(c *httpapi.Ctx) error {
search := strings.TrimSpace(c.Q("search"))
bind := strings.TrimSpace(c.Q("bind"))
if !c.QHas("bind") {
bind = "all"
}
sort := strings.TrimSpace(c.Q("sort"))
if !c.QHas("sort") {
sort = "newest"
}
sql := "SELECT u.id, u.user_id, u.openid, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.couple_id, u.created_at, COALESCE(u.last_login_at, u.created_at) as last_login_at, u.location, u.last_login_location, u.last_ip, CASE WHEN u.couple_id IS NOT NULL THEN '已绑定' ELSE '未绑定' END as status FROM users u"
var where []string
var params []any
if bind == "bound" {
where = append(where, "u.couple_id IS NOT NULL")
} else if bind == "unbound" {
where = append(where, "u.couple_id IS NULL")
}
if search != "" {
like := "%" + search + "%"
if isNumeric(search) {
where = append(where, "(u.id = ? OR u.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
params = append(params, store.ToInt(search), store.ToInt(search), like, like)
} else {
where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)")
params = append(params, like, like)
}
}
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
if sort == "last_login" {
sql += " ORDER BY COALESCE(u.last_login_at, u.created_at) DESC"
} else {
sql += " ORDER BY u.created_at DESC"
}
rs, err := c.DB.Query(sql, params...)
if err != nil {
return err
}
return c.OK(paged(c, rs))
}
func adminFixLastLogin(c *httpapi.Ctx) error {
res, err := c.DB.Exec("UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL")
if err != nil {
return err
}
n, _ := res.RowsAffected()
return c.OK(M{"success": true, "count": n, "msg": fmt.Sprintf("已补全 %d 个用户的最后登录时间", n)})
}
func adminCleanLocations(c *httpapi.Ctx) error {
res, err := c.DB.Exec(`UPDATE users SET location = '' WHERE location REGEXP '^-?[0-9]+(\\.[0-9]+)?,-?[0-9]+(\\.[0-9]+)?$'`)
if err != nil {
return err
}
n, _ := res.RowsAffected()
return c.OK(M{"success": true, "count": n, "msg": fmt.Sprintf("已清理 %d 个经纬度格式的位置数据", n)})
}
func adminUserDetail(c *httpapi.Ctx) error {
uid := c.QInt("user_id")
if uid == 0 {
return c.Err(400, "缺少用户ID")
}
user, err := c.DB.QueryOne("SELECT id, user_id, openid, username, nickname, avatar, gender, age, birthday, couple_id, created_at, last_login_at, region, location, last_login_location FROM users WHERE id = ?", uid)
if err != nil {
return err
}
if user == nil {
return c.Err(404, "用户不存在")
}
if user.Int("couple_id") != 0 {
partner, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, location, last_login_location FROM users WHERE couple_id = ? AND id != ?", user.Int("couple_id"), uid)
user["partner"] = rowOrFalse(partner)
}
logs, err := c.DB.Query("SELECT cl.*, c.title as checkin_title, c.icon as checkin_icon, u2.nickname as creator_name FROM checkin_logs cl LEFT JOIN checkins c ON cl.checkin_id = c.id LEFT JOIN users u2 ON c.created_by = u2.id WHERE cl.user_id = ? ORDER BY cl.created_at DESC LIMIT 100", uid)
if err != nil {
return err
}
user["checkin_logs"] = rows(logs)
checkins, err := c.DB.Query("SELECT * FROM checkins WHERE created_by = ? ORDER BY created_at DESC LIMIT 50", uid)
if err != nil {
return err
}
for _, ck := range checkins {
l, _ := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC LIMIT 30", ck.Int("id"))
ck["logs"] = rows(l)
}
user["checkins"] = rows(checkins)
diaries, err := c.DB.Query("SELECT * FROM diaries WHERE created_by = ? ORDER BY diary_date DESC LIMIT 50", uid)
if err != nil {
return err
}
user["diaries"] = rows(diaries)
albums, err := c.DB.Query("SELECT * FROM albums WHERE created_by = ? ORDER BY created_at DESC LIMIT 50", uid)
if err != nil {
return err
}
user["albums"] = rows(albums)
return c.OK(user)
}
func adminChangePassword(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
if len(c.InStr("new_password")) < 6 {
return c.Err(400, "新密码至少6位")
}
sess := c.AdminSession()
admin, _ := c.DB.QueryOne("SELECT * FROM admins WHERE id = ?", sess.AdminID)
if admin != nil && verifyPassword(c.InStr("old_password"), admin.Str("password")) {
if _, err := c.DB.Exec("UPDATE admins SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", hashPassword(c.InStr("new_password")), sess.AdminID); err != nil {
return err
}
return c.OK(M{"success": true})
}
return c.Err(400, "原密码错误")
}
func adminChangeUsername(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
name := c.InTrim("new_username")
if !store.Truthy(name) {
return c.Err(400, "用户名不能为空")
}
sess := c.AdminSession()
if _, err := c.DB.Exec("UPDATE admins SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", name, sess.AdminID); err != nil {
return err
}
sess.Username = name
return c.OK(M{"success": true})
}
func adminUpdateUser(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
userID := c.InInt("user_id")
if userID <= 0 {
return c.Err(400, "用户ID无效")
}
var fields []string
var params []any
if nick := c.InTrim("nickname"); store.Truthy(nick) {
fields = append(fields, "nickname = ?")
params = append(params, nick)
}
if g := c.InStr("gender"); store.Truthy(g) {
fields = append(fields, "gender = ?")
params = append(params, g)
}
if c.InHas("age") {
fields = append(fields, "age = ?")
params = append(params, c.InInt("age"))
}
if b := c.InTrim("birthday"); b != "" {
fields = append(fields, "birthday = ?")
params = append(params, b)
}
if len(fields) > 0 {
fields = append(fields, "updated_at = CURRENT_TIMESTAMP")
params = append(params, userID)
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err
}
}
return c.OK(M{"success": true})
}
func adminDeleteUser(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
target := c.InInt("user_id")
if target <= 0 {
return c.Err(400, "用户ID无效")
}
tu, _ := c.DB.QueryOne("SELECT id, couple_id FROM users WHERE id = ?", target)
if tu == nil {
return c.Err(404, "用户不存在")
}
deleteIDs := []any{target}
if cid := tu.Int("couple_id"); cid != 0 {
partners, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? AND id != ?", cid, target)
for _, p := range partners {
deleteIDs = append(deleteIDs, p.Int("id"))
}
for _, t := range []string{"timeline", "diaries", "songs", "checkin_logs", "checkins"} {
c.DB.Exec("DELETE FROM "+t+" WHERE couple_id = ?", cid)
}
albums, _ := c.DB.Query("SELECT id FROM albums WHERE couple_id = ?", cid)
for _, a := range albums {
c.DB.Exec("DELETE FROM photos WHERE album_id = ?", a.Int("id"))
}
c.DB.Exec("DELETE FROM albums WHERE couple_id = ?", cid)
c.DB.Exec("DELETE FROM couples WHERE id = ?", cid)
}
ph := placeholders(len(deleteIDs))
c.DB.Exec("DELETE FROM invitations WHERE from_user_id IN ("+ph+") OR to_user_id IN ("+ph+")", append(append([]any{}, deleteIDs...), deleteIDs...)...)
c.DB.Exec("DELETE FROM invite_links WHERE user_id IN ("+ph+")", deleteIDs...)
if _, err := c.DB.Exec("DELETE FROM users WHERE id IN ("+ph+")", deleteIDs...); err != nil {
return err
}
msg := "已删除用户"
if len(deleteIDs) > 1 {
msg = fmt.Sprintf("已删除用户及伴侣(共%d个账号)及相关情侣数据", len(deleteIDs))
}
return c.OK(M{"success": true, "msg": msg, "deleted_count": len(deleteIDs)})
}
var unbindStatusLabels = map[string]string{
"pending": "待对方回应", "cooling": "冷静期", "completed": "解绑完成", "rejected": "已拒绝", "cancelled": "已撤销", "expired": "已过期",
}
func adminUnbindRequests(c *httpapi.Ctx) error {
if err := c.RequireGET(); err != nil {
return err
}
rs, err := c.DB.Query(`
SELECT
ur.id, ur.couple_id, ur.status, ur.created_at, ur.responded_at, ur.cooling_until, ur.completed_at, ur.cancelled_at,
cancel_user.nickname AS cancelled_by_name,
r.id as requester_id, r.user_id as requester_uid, r.nickname as requester_name, r.avatar as requester_avatar,
c.couple_space_id, c.data_deleted, c.status as couple_status,
u1.nickname as user1_name, u1.user_id as user1_uid,
u2.nickname as user2_name, u2.user_id as user2_uid
FROM unbind_requests ur
JOIN users r ON ur.requester_id = r.id
LEFT JOIN users cancel_user ON ur.cancelled_by = cancel_user.id
JOIN couples c ON ur.couple_id = c.id
LEFT JOIN users u1 ON c.user1_id = u1.id
LEFT JOIN users u2 ON c.user2_id = u2.id
WHERE NOT (c.status = 'active' AND ur.status = 'agreed')
UNION
SELECT
NULL as id, c.id as couple_id, 'completed' as status, NULL as created_at, NULL as responded_at, NULL as cooling_until, NULL as completed_at, NULL as cancelled_at, NULL as cancelled_by_name,
NULL as requester_id, NULL as requester_uid, NULL as requester_name, NULL as requester_avatar,
c.couple_space_id, c.data_deleted, c.status as couple_status,
u1.nickname as user1_name, u1.user_id as user1_uid,
u2.nickname as user2_name, u2.user_id as user2_uid
FROM couples c
LEFT JOIN users u1 ON c.user1_id = u1.id
LEFT JOIN users u2 ON c.user2_id = u2.id
WHERE c.status = 'unbound'
AND c.id NOT IN (SELECT DISTINCT couple_id FROM unbind_requests)
ORDER BY created_at DESC`)
if err != nil {
return err
}
filter := c.Q("status")
out := make([]store.Row, 0, len(rs))
for _, r := range rs {
st := r.Str("status")
if filter != "" && st != filter {
continue
}
if l, ok := unbindStatusLabels[st]; ok {
r["status_label"] = l
} else {
r["status_label"] = r["status"]
}
out = append(out, r)
}
return c.OK(paged(c, out))
}
// coupleUserNames 对应 array_map(fn($u) => $u['nickname'] ?: '#' . $u['user_id'], $users) 后 implode(' & ')
func coupleUserNames(users []store.Row) string {
names := make([]string, 0, len(users))
for _, u := range users {
n := u.Str("nickname")
if !store.Truthy(n) {
n = "#" + u.Str("user_id")
}
names = append(names, n)
}
return strings.Join(names, " & ")
}
func adminForceUnbind(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := c.InInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ?", cid)
if couple == nil {
return c.Err(404, "情侣不存在")
}
if err := c.DB.AdvanceUnbindings(); err != nil {
return err
}
current, err := c.DB.QueryOne("SELECT status FROM couples WHERE id=?", cid)
if err != nil {
return err
}
if current.Str("status") != "unbound" {
return c.Err(409, "需双方同意并等待72小时冷静期结束,不能提前解绑")
}
return c.OK(M{"success": true, "msg": "冷静期已结束,系统已自动解绑并处理共同数据"})
}
func adminRestoreCouple(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := c.InInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound' AND data_deleted = 0", cid)
if couple == nil {
return c.Err(400, "该情侣记录不存在或数据已被删除,无法恢复")
}
return c.Err(409, "旧空间已封存,恢复关系请由双方重新发起绑定,后台不能覆盖当前关系")
}
func adminDeleteCoupleData(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := c.InInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound'", cid)
if couple == nil {
return c.Err(400, "该情侣记录不存在或状态异常,无法删除数据")
}
if couple.Int("data_deleted") == 1 {
return c.OK(M{"success": true, "msg": "共同数据已删除,解绑记录保留"})
}
users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", couple.Int("user1_id"), couple.Int("user2_id"))
c.DB.Exec("UPDATE couples SET data_deleted = 1 WHERE id = ?", cid)
executeUnbind(c.DB, cid)
return c.OK(M{"success": true, "msg": "已删除情侣「" + coupleUserNames(users) + "」的所有数据记录"})
}
func adminDeleteContent(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
typ := c.InStr("type")
id, coupleID := c.InInt("id"), c.InInt("couple_id")
if id == 0 || coupleID == 0 {
return c.Err(400, "参数不完整")
}
switch typ {
case "timeline":
c.DB.Exec("DELETE FROM timeline WHERE id = ? AND couple_id = ?", id, coupleID)
case "diary":
c.DB.Exec("DELETE FROM diaries WHERE id = ? AND couple_id = ?", id, coupleID)
case "album":
c.DB.Exec("DELETE FROM photos WHERE album_id = ?", id)
c.DB.Exec("DELETE FROM albums WHERE id = ? AND couple_id = ?", id, coupleID)
case "photo":
if p, _ := c.DB.QueryOne("SELECT path FROM photos WHERE id = ?", id); p != nil {
up.Remove(p.Str("path"))
c.DB.Exec("DELETE FROM photos WHERE id = ?", id)
}
case "song":
c.DB.Exec("DELETE FROM songs WHERE id = ? AND couple_id = ?", id, coupleID)
case "checkin":
c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id)
c.DB.Exec("DELETE FROM checkins WHERE id = ? AND couple_id = ?", id, coupleID)
default:
return c.Err(400, "不支持的内容类型: "+typ)
}
return c.OK(M{"success": true, "msg": "已删除"})
}
func adminContents(c *httpapi.Ctx) error {
typ := c.Q("type")
if !c.QHas("type") {
typ = "diary"
}
coupleID, userID := c.QInt("couple_id"), c.QInt("user_id")
startDate, endDate := c.Q("start_date"), c.Q("end_date")
page, pageSize, offset := pageParams(c, 10, 50)
var where []string
var params []any
if coupleID > 0 {
where = append(where, "t.couple_id = ?")
params = append(params, coupleID)
}
if userID > 0 {
where = append(where, "t.created_by = ?")
params = append(params, userID)
}
if startDate != "" {
where = append(where, "t.created_at >= ?")
params = append(params, startDate+" 00:00:00")
}
if endDate != "" {
where = append(where, "t.created_at <= ?")
params = append(params, endDate+" 23:59:59")
}
whereClause := ""
if len(where) > 0 {
whereClause = "WHERE " + strings.Join(where, " AND ")
}
coupleList, err := c.DB.Query("SELECT c.id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id ORDER BY c.id DESC")
if err != nil {
return err
}
userList, err := c.DB.Query("SELECT id, user_id, nickname, username FROM users ORDER BY id DESC")
if err != nil {
return err
}
const cols = "t.*, u.nickname as author_name, u.user_id as author_display_id, u.avatar as author_avatar"
var sql, countSQL string
switch typ {
case "diary":
sql = "SELECT " + cols + ", t.couple_id FROM diaries t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM diaries t " + whereClause
case "timeline":
sql = "SELECT " + cols + ", t.couple_id FROM timeline t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM timeline t " + whereClause
case "album":
sql = "SELECT " + cols + ", t.couple_id, (SELECT COUNT(*) FROM photos WHERE album_id = t.id) as photo_count FROM albums t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM albums t " + whereClause
case "photo":
sql = "SELECT " + cols + ", a.couple_id FROM photos t JOIN albums a ON t.album_id = a.id LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM photos t JOIN albums a ON t.album_id = a.id " + whereClause
case "song":
sql = "SELECT " + cols + ", t.couple_id FROM songs t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM songs t " + whereClause
case "checkin":
sql = "SELECT " + cols + ", t.couple_id FROM checkins t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM checkins t " + whereClause
default:
return c.Err(400, "不支持的内容类型")
}
total := countOf(c, countSQL, params...)
items, err := c.DB.Query(sql, append(append([]any{}, params...), pageSize, offset)...)
if err != nil {
return err
}
for _, it := range items {
if _, ok := it["images"]; ok && it["images"] != nil {
it["images"] = jsonDecodeList(it["images"])
}
}
return c.OK(M{
"items": rows(items), "total": total, "page": page, "page_size": pageSize,
"total_pages": math.Ceil(float64(total) / float64(pageSize)),
"couples": rows(coupleList), "users": rows(userList),
})
}
func adminResetUserPassword(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
userID := c.InInt("user_id")
pw := c.InStr("new_password")
if userID <= 0 {
return c.Err(400, "用户ID无效")
}
if len(pw) < 6 {
return c.Err(400, "新密码至少6位")
}
if _, err := c.DB.Exec("UPDATE users SET password = ? WHERE id = ?", hashPassword(pw), userID); err != nil {
return err
}
return c.OK(M{"success": true, "message": "密码重置成功"})
}
func adminActivityLog(c *httpapi.Ctx) error {
cid := c.QInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
rs, err := c.DB.Query("SELECT al.*, u.nickname, u.gender FROM activity_log al JOIN users u ON al.user_id = u.id WHERE al.couple_id = ? ORDER BY al.created_at DESC LIMIT 100", cid)
if err != nil {
return err
}
return c.OK(rows(rs))
}
func adminGetGlobalBg(c *httpapi.Ctx) error {
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = 'bg_global'")
if row == nil {
return c.OK(M{"bg_image": nil})
}
return c.OK(M{"bg_image": row["value"]})
}
func adminSetGlobalBg(c *httpapi.Ctx) error {
fh := c.File("bg")
if fh == nil {
return c.Err(400, "没有上传文件")
}
res := up.Handle(fh, "", "global")
if res.Error != "" {
return c.Err(400, res.Error)
}
if _, err := c.DB.Exec("INSERT INTO settings (`key`, `value`) VALUES ('bg_global', ?) ON DUPLICATE KEY UPDATE `value` = ?, updated_at = CURRENT_TIMESTAMP", res.Path, res.Path); err != nil {
return err
}
return c.OK(M{"success": true, "path": res.Path})
}
func adminClearGlobalBg(c *httpapi.Ctx) error {
if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = 'bg_global'"); err != nil {
return err
}
return c.OK(M{"success": true})
}
func adminCoupleBgList(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT c.id as couple_id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name, CONCAT(u1.nickname, ' & ', u2.nickname) as couple_name, s1.value as bg_image FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id LEFT JOIN settings s1 ON s1.`key` = CONCAT('bg_', c.id) ORDER BY c.id DESC")
if err != nil {
return err
}
return c.OK(rows(rs))
}
func adminClearCoupleBg(c *httpapi.Ctx) error {
cid := c.InInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = ? OR `key` = ?", fmt.Sprintf("bg_%d", cid), fmt.Sprintf("bgver_%d", cid)); err != nil {
return err
}
return c.OK(M{"success": true})
}
@@ -0,0 +1,64 @@
package handler
import (
"math"
"strings"
"time"
"past/internal/httpapi"
)
func adminSubscribeLogs(c *httpapi.Ctx) error {
where := []string{"1=1"}
args := []any{}
status := c.Q("status")
if status != "" {
if status != "success" && status != "failed" {
return c.Err(400, "发送状态无效")
}
where = append(where, "l.status = ?")
args = append(args, status)
}
if action := strings.TrimSpace(c.Q("action_type")); action != "" {
where = append(where, "l.action_type = ?")
args = append(args, action)
}
if keyword := strings.TrimSpace(c.Q("keyword")); keyword != "" {
like := "%" + keyword + "%"
where = append(where, "(l.to_openid LIKE ? OR recipient.nickname LIKE ? OR recipient.user_id LIKE ?)")
args = append(args, like, like, like)
}
var from, to time.Time
for _, key := range []string{"start_date", "end_date"} {
if value := c.Q(key); value != "" {
date, err := time.Parse("2006-01-02", value)
if err != nil {
return c.Err(400, "日期格式错误,请使用 YYYY-MM-DD")
}
if key == "start_date" {
from = date
where = append(where, "l.created_at >= ?")
args = append(args, date.Format("2006-01-02"))
} else {
to = date
where = append(where, "l.created_at < ?")
args = append(args, date.AddDate(0, 0, 1).Format("2006-01-02"))
}
}
}
if !from.IsZero() && !to.IsZero() && from.After(to) {
return c.Err(400, "开始日期不能晚于结束日期")
}
base := " FROM subscribe_message_logs l LEFT JOIN users recipient ON recipient.id=l.to_user_id LEFT JOIN users sender ON sender.id=l.user_id WHERE " + strings.Join(where, " AND ")
total, err := c.DB.QueryOne("SELECT COUNT(*) AS total"+base, args...)
if err != nil {
return err
}
page, size, offset := pageParams(c, 10, 100)
items, err := c.DB.Query("SELECT l.*, recipient.nickname AS recipient_name, recipient.user_id AS recipient_uid, sender.nickname AS sender_name"+base+" ORDER BY l.id DESC LIMIT ? OFFSET ?", append(args, size, offset)...)
if err != nil {
return err
}
count := total.Int("total")
return c.OK(M{"items": rows(items), "total": count, "page": page, "page_size": size, "total_pages": int64(math.Ceil(float64(count) / float64(size)))})
}
@@ -0,0 +1,38 @@
package handler
import (
"database/sql/driver"
"past/internal/testdb"
"strings"
"testing"
)
func TestAdminSubscribeLogsFilteredServerPagination(t *testing.T) {
filters := []any{"failed", "diary_create", "%recipient%", "%recipient%", "%recipient%", "2026-10-01", "2026-10-12"}
predicate := "l.status = ? AND l.action_type = ? AND (l.to_openid LIKE ? OR recipient.nickname LIKE ? OR recipient.user_id LIKE ?) AND l.created_at >= ? AND l.created_at < ?"
c, out, _ := permissionCtx(t, "GET", "/api/admin_subscribe_logs?status=failed&action_type=diary_create&keyword=recipient&start_date=2026-10-01&end_date=2026-10-11&page=2&page_size=10", "", []testdb.Step{
{Match: "SELECT COUNT(*) AS total", Args: filters, Check: func(q string, _ []any) error {
if !strings.Contains(q, predicate) {
t.Errorf("wrong filters %s", q)
}
return nil
}, Columns: []string{"total"}, Rows: [][]driver.Value{{int64(21)}}},
{Match: "ORDER BY l.id DESC LIMIT ? OFFSET ?", Args: append(filters, int64(10), int64(10)), Columns: []string{"id", "to_openid", "status", "error_msg"}, Rows: [][]driver.Value{{int64(11), "recipient-openid", "failed", "用户未订阅"}}},
})
adminSubscribeLogs(c)
for _, want := range []string{`"total":21`, `"total_pages":3`, `"page":2`, `recipient-openid`, `用户未订阅`} {
if !strings.Contains(out.Body.String(), want) {
t.Fatalf("missing %s: %s", want, out.Body.String())
}
}
}
func TestAdminSubscribeLogsRejectsInvalidFilters(t *testing.T) {
for _, query := range []string{"status=invalid", "start_date=2026-02-30", "start_date=2026-10-12&end_date=2026-10-01"} {
c, out, _ := permissionCtx(t, "GET", "/api/admin_subscribe_logs?"+query, "", nil)
adminSubscribeLogs(c)
if out.Code != 400 {
t.Fatalf("invalid filters accepted: %s", query)
}
}
}
+423
View File
@@ -0,0 +1,423 @@
package handler
import (
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
func registerAlbum(r *httpapi.Router) {
r.Handle("album_list", albumList)
r.Handle("album_add", albumAdd)
r.Handle("album_update", albumUpdate)
r.Handle("album_delete", albumDelete)
r.Handle("set_album_cover", setAlbumCover)
r.Handle("photos", photos)
r.Handle("photo_upload", photoUpload)
r.Handle("photo_delete", photoDelete)
r.Handle("photo_add", photoAdd)
r.Handle("photo_update", photoUpdate)
}
// coupleSpaceID 对应 SELECT couple_space_id FROM couples WHERE id = ?
func coupleSpaceID(c *httpapi.Ctx, cid int64) string {
row, _ := c.DB.QueryOne("SELECT couple_space_id FROM couples WHERE id = ?", cid)
if row == nil {
return ""
}
return row.Str("couple_space_id")
}
func albumList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
photoTotal := int64(0)
for _, album := range demoData("album") {
photoTotal += store.ToInt(album["photo_count"])
}
return demoListResponse(c, "album", M{"photo_total": photoTotal})
}
suffix, window := listWindow(c)
items, err := c.DB.Query("SELECT a.*, (SELECT COUNT(*) FROM photos WHERE album_id = a.id) as photo_count, (SELECT p.path FROM photos p WHERE p.album_id = a.id ORDER BY p.created_at DESC LIMIT 1) as latest_photo FROM albums a WHERE a.created_by IN (?, ?) ORDER BY a.sort_order ASC, a.created_at DESC, a.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...)
if err != nil {
return err
}
for _, it := range items {
if store.Truthy(it["latest_photo"]) {
it["cover"] = it["latest_photo"]
}
}
total := countC(c, "SELECT COUNT(*) as c FROM albums WHERE created_by IN (?, ?)", ids[0], ids[1])
photoTotal := countC(c, "SELECT COUNT(*) as c FROM photos p JOIN albums a ON p.album_id=a.id WHERE a.created_by IN (?, ?)", ids[0], ids[1])
return listResponse(c, items, total, M{"photo_total": photoTotal})
}
func albumAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
return err
}
lat, lng := c.InFloat("lat"), c.InFloat("lng")
location := c.InTrim("location")
if lat != 0 && lng != 0 && location == "" {
location = c.App.Geo.ReverseGeocode(lat, lng)
}
id, err := c.DB.Insert("INSERT INTO albums (couple_id, title, description, cover, sort_order, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, c.In("title"), inOr(c, "description", ""), inOr(c, "cover", ""), inOr(c, "sort_order", 0), lat, lng, location, uid)
if err != nil {
return err
}
title := c.InStr("title")
actID := logActivity(c.DB, cid, uid, "album_create", "创建了相册「"+title+"」", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "新相册「" + mbTruncate(title, 20) + "」",
"thing2": "TA创建了新相册,快来一起看看吧 📸",
"date3": today(),
"thing4": "点击查看详情",
}, "pages/albums/albums", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
func albumUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
return err
}
title, desc, sort, id := c.In("title"), inOr(c, "description", ""), inOr(c, "sort_order", 0), c.In("id")
hasCover, hasLat := c.InHas("cover"), c.InHas("lat")
switch {
case hasCover && hasLat:
_, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, cover = ?, sort_order = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)",
title, desc, c.In("cover"), sort, c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), id, ids[0], ids[1])
case hasCover:
_, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, cover = ?, sort_order = ? WHERE id = ? AND created_by IN (?, ?)",
title, desc, c.In("cover"), sort, id, ids[0], ids[1])
case hasLat:
_, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, sort_order = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)",
title, desc, sort, c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), id, ids[0], ids[1])
default:
_, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, sort_order = ? WHERE id = ? AND created_by IN (?, ?)",
title, desc, sort, id, ids[0], ids[1])
}
if err != nil {
return err
}
return c.OK(M{"success": true})
}
func albumDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
id := c.In("id")
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1]); a == nil {
return c.Err(404, "相册不存在")
}
if cnt, _ := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE album_id = ?", id); cnt != nil && cnt.Int("cnt") > 0 {
return c.Err(400, "请先删除相册中的全部照片")
}
if _, err := c.DB.Exec("DELETE FROM albums WHERE id = ?", id); err != nil {
return err
}
return c.OK(M{"success": true})
}
func setAlbumCover(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
ids := coupleUserIds(c, 0)
albumID := store.ToInt(c.Form("album_id"))
var a store.Row
if len(ids) == 2 {
a, _ = c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, ids[0], ids[1])
}
if a == nil {
return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound)
}
spaceID := coupleSpaceID(c, c.CoupleID())
fh := c.File("cover")
if fh == nil {
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
}
res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos"))
if res.Path != "" {
if _, err := c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", res.Path, albumID); err != nil {
return err
}
return c.OK(M{"success": true, "path": res.Path})
}
if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
}
return c.ErrCode(400, "上传失败: "+orDefault(res.Error, "未知错误"), upload.ErrHandleFailed)
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func photos(c *httpapi.Ctx) error {
albumID := c.Q("album_id")
if albumID == "" {
albumID = "0"
}
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return listResponse(c, nil, 0, nil)
}
suffix, window := listWindow(c)
items, err := c.DB.Query("SELECT p.* FROM photos p JOIN albums a ON p.album_id = a.id WHERE a.created_by IN (?, ?) AND p.album_id = ? ORDER BY p.sort_order ASC, p.created_at DESC, p.id DESC"+suffix, append([]any{ids[0], ids[1], albumID}, window...)...)
if err != nil {
return err
}
return listResponse(c, items, countC(c, "SELECT COUNT(*) as c FROM photos p JOIN albums a ON p.album_id=a.id WHERE a.created_by IN (?, ?) AND p.album_id=?", ids[0], ids[1], albumID), nil)
}
// setFirstCover 相册第一张照片时自动设为封面
func setFirstCover(c *httpapi.Ctx, albumID any, path string) {
if cnt, _ := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE album_id = ?", albumID); cnt != nil && cnt.Int("cnt") == 1 {
c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ? AND (cover IS NULL OR cover = '')", path, albumID)
}
}
func photoUpload(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
uid := c.UID()
if uid == 0 {
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
}
cid := c.CoupleID()
if cid == 0 {
return c.ErrCode(400, "请先绑定TA", upload.ErrNotBound)
}
spaceID := coupleSpaceID(c, cid)
albumID := c.Form("album_id")
if albumID == "" {
albumID = "0"
}
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil {
return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound)
}
description := c.Form("description")
if err := checkTextOr400(c, strings.TrimSpace(description)); err != nil {
return err
}
uploaded := []string{}
errors := []string{}
files := c.Files("photos")
if len(files) == 0 {
files = c.Files("file")
}
if len(files) == 0 {
// 兼容某些安卓端直接传二进制:从原始请求体读取
raw := c.Body()
if len(raw) > 100 {
res := up.SaveRaw(raw, "", "jpg", "", upload.CouplePath(spaceID, "photos"))
if res.Path != "" {
c.DB.Exec("INSERT INTO photos (album_id, path, description, created_by) VALUES (?, ?, ?, ?)", albumID, res.Path, description, uid)
uploaded = append(uploaded, res.Path)
setFirstCover(c, albumID, res.Path)
} else if strings.Contains(res.Error, "违规") {
errors = append(errors, "所发布内容含违规信息")
}
}
if len(uploaded) == 0 {
return c.ErrCode(400, "未接收到文件,请重试", upload.ErrNoFileReceived)
}
}
for _, fh := range files {
res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos"))
if res.Path != "" {
c.DB.Exec("INSERT INTO photos (album_id, path, description, taken_at, location, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
albumID, res.Path, description, c.Form("taken_at"), c.Form("location"), c.Form("activity"), c.Form("batch_id"), uid)
uploaded = append(uploaded, res.Path)
setFirstCover(c, albumID, res.Path)
} else {
errors = append(errors, fh.Filename+": "+orDefault(res.Error, "未知错误"))
}
}
if len(uploaded) == 0 && len(errors) > 0 {
return c.ErrCode(400, "上传失败: "+strings.Join(errors, "; "), upload.ErrHandleFailed)
}
if len(uploaded) > 0 {
actID := logActivity(c.DB, cid, uid, "photo_upload", fmt.Sprintf("上传了%d张照片", len(uploaded)), "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "相册有更新",
"thing2": fmt.Sprintf("TA上传了%d张新照片 📸", len(uploaded)),
"date3": today(),
"thing4": "点击查看详情",
}, "pages/albums/albums", actID, uid)
}
}
return c.OK(M{"success": true, "uploaded": len(uploaded), "errors": errors})
}
func photoDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
id := c.In("id")
photo, err := c.DB.QueryOne("SELECT p.path, p.album_id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if photo != nil {
albumID := photo.Int("album_id")
up.Remove(photo.Str("path"))
c.DB.Exec("DELETE FROM photos WHERE id = ?", id)
album, _ := c.DB.QueryOne("SELECT cover FROM albums WHERE id = ?", albumID)
if album != nil && album.Str("cover") == photo.Str("path") && !album.IsNull("cover") {
next, _ := c.DB.QueryOne("SELECT path FROM photos WHERE album_id = ? ORDER BY created_at ASC LIMIT 1", albumID)
newCover := ""
if next != nil {
newCover = next.Str("path")
}
c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", newCover, albumID)
}
}
return c.OK(M{"success": true})
}
func photoAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
if !c.InBool("album_id") || !c.InBool("path") {
return c.Err(400, "参数不完整")
}
albumID := c.In("album_id")
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil {
return c.Err(404, "相册不存在")
}
uid := c.UID()
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("description")+" "+c.InStr("activity"))); err != nil {
return err
}
location, lat, lng := photoLocation(c)
path := c.In("path")
id, err := c.DB.Insert("INSERT INTO photos (album_id, path, description, taken_at, location, lat, lng, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
albumID, path, inOr(c, "description", ""), inOr(c, "taken_at", ""), location, lat, lng, inOr(c, "activity", ""), inOr(c, "batch_id", ""), uid)
if err != nil {
return err
}
setFirstCover(c, albumID, c.InStr("path"))
actID := logActivity(c.DB, cid, uid, "photo_upload", "上传了照片", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "相册有更新",
"thing2": "TA上传了新照片 📸",
"date3": today(),
"thing4": "点击查看详情",
}, "pages/albums/albums", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id), "path": path})
}
// photoUpdate 仅修改照片信息,权限范围与照片列表保持一致。
func photoUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
id := c.InInt("id")
if id <= 0 || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") {
return c.Err(400, "参数不完整")
}
location, lat, lng := photoLocation(c)
takenAt, activity := c.InTrim("taken_at"), c.InTrim("activity")
if err := validatePhotoMetadata(takenAt, location, activity); err != nil {
return c.Err(400, err.Error())
}
photo, err := c.DB.QueryOne("SELECT p.id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if photo == nil {
return c.Err(404, "照片不存在或无权编辑")
}
if err := checkTextOr400(c, strings.TrimSpace(location+" "+activity)); err != nil {
return err
}
_, err = c.DB.Exec("UPDATE photos p JOIN albums a ON p.album_id = a.id SET p.taken_at = ?, p.location = ?, p.activity = ?, p.lat = ?, p.lng = ? WHERE p.id = ? AND a.created_by IN (?, ?)", takenAt, location, activity, lat, lng, id, ids[0], ids[1])
if err != nil {
return err
}
return c.OK(M{"success": true, "id": id, "taken_at": takenAt, "location": location, "activity": activity})
}
func validatePhotoMetadata(takenAt, location, activity string) error {
if takenAt != "" {
date, err := time.Parse("2006-01-02", takenAt)
if err != nil || date.Format("2006-01-02") != takenAt {
return errors.New("拍摄时间格式不正确")
}
}
if utf8.RuneCountInString(location) > 200 {
return errors.New("地点不能超过200字")
}
if utf8.RuneCountInString(activity) > 200 {
return errors.New("在做什么不能超过200字")
}
return nil
}
// 用户填写的地址优先;留空时定位解析,最后使用当前用户保存的地址。
func photoLocation(c *httpapi.Ctx) (string, float64, float64) {
location := c.InTrim("location")
lat, lng := c.InFloat("lat"), c.InFloat("lng")
if location != "" {
return location, lat, lng
}
if lat != 0 && lng != 0 {
location = c.App.Geo.ReverseGeocode(lat, lng)
}
if location == "" {
if user, err := c.DB.QueryOne("SELECT location FROM users WHERE id = ?", c.UID()); err == nil && user != nil {
location = strings.TrimSpace(user.Str("location"))
}
}
return location, lat, lng
}
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"strings"
"testing"
)
func TestValidatePhotoMetadata(t *testing.T) {
for _, tt := range []struct {
name, date, location, activity string
valid bool
}{
{"clear fields", "", "", "", true},
{"leap day", "2024-02-29", "北京颐和园", "第一次一起看日落", true},
{"invalid day", "2026-02-29", "", "", false},
{"date format", "2026-2-1", "", "", false},
{"time not accepted", "2026-10-07 09:54", "", "", false},
{"unicode at limit", "", strings.Repeat("景", 200), strings.Repeat("爱", 200), true},
{"location too long", "", strings.Repeat("景", 201), "", false},
{"story too long", "", "", strings.Repeat("爱", 201), false},
} {
t.Run(tt.name, func(t *testing.T) {
if got := validatePhotoMetadata(tt.date, tt.location, tt.activity); (got == nil) != tt.valid {
t.Fatalf("valid=%v, error=%v", tt.valid, got)
}
})
}
}
+462
View File
@@ -0,0 +1,462 @@
package handler
import (
"fmt"
"time"
"past/internal/httpapi"
"past/internal/store"
)
func registerCheckin(r *httpapi.Router) {
r.Handle("checkin_list", checkinList)
r.Handle("checkin_add", checkinAdd)
r.Handle("checkin_do", checkinDo)
r.Handle("checkin_uncheck", checkinUncheck)
r.Handle("checkin_detail", checkinDetail)
r.Handle("checkin_stats", checkinStats)
r.Handle("checkin_toggle", checkinToggle)
r.Handle("checkin_delete", checkinDelete)
r.Handle("smoking_info", smokingInfo)
r.Handle("smoking_action", smokingAction)
}
// weekMonday 对应 date('Y-m-d', strtotime('monday this week')):ISO 周,周日归上一个周一
func weekMonday() string {
t := time.Now()
wd := int(t.Weekday())
if wd == 0 {
wd = 7
}
return t.AddDate(0, 0, -(wd - 1)).Format("2006-01-02")
}
func monthStart() string { return time.Now().Format("2006-01") + "-01" } // date('Y-m-01')
func yearStart() string { return time.Now().Format("2006") + "-01-01" } // date('Y-01-01')
var checkinTypeLabels = map[string]string{"both": "双方", "first": "第一视角", "her": "她打卡"}
// first10 对应 $t ? substr($t, 0, 10) : null;返回 (值, 是否非空)
func first10(v any) (string, bool) {
s := store.ToStr(v)
if !store.Truthy(s) {
return "", false
}
if len(s) > 10 {
s = s[:10]
}
return s, true
}
// periodDone 周期完成判定
func periodDone(freq string, last string, has bool, today, monday, month string) bool {
switch freq {
case "once":
return has
case "unlimited":
return false
case "monthly":
return has && last >= month
case "weekly":
return has && last >= monday
default:
return has && last == today
}
}
// decorateCheckin 为 checkin_list / checkin_detail 的单条记录补充视角字段
func decorateCheckin(item store.Row, uid int64, withAvatar bool) {
isCreator := uid == item.Int("created_by")
freq := store.ToStr(item["frequency"])
if item.IsNull("frequency") {
freq = "daily"
}
typ := store.ToStr(item["type"])
if item.IsNull("type") {
typ = "both"
}
label, ok := checkinTypeLabels[typ]
if !ok {
label = "双方"
}
item["type_label"] = label
var myTime, partnerTime any
if isCreator {
myTime, partnerTime = item["my_checkin_time"], item["partner_checkin_time"]
} else {
myTime, partnerTime = item["partner_checkin_time"], item["my_checkin_time"]
}
td, mon, ms := today(), weekMonday(), monthStart()
myLast, myHas := first10(myTime)
partnerLast, partnerHas := first10(partnerTime)
myDone := periodDone(freq, myLast, myHas, td, mon, ms)
partnerDone := periodDone(freq, partnerLast, partnerHas, td, mon, ms)
item["my_done_today"] = myDone
item["partner_done_today"] = partnerDone
switch typ {
case "first":
item["can_checkin"] = isCreator && !myDone
case "her":
item["can_checkin"] = !isCreator && !partnerDone
default:
item["can_checkin"] = !myDone
}
completed := 0
if typ == "both" {
if myDone && partnerDone {
completed = 1
}
} else if (typ == "first" && myDone) || (typ != "first" && partnerDone) {
completed = 1
}
item["is_completed"] = completed
// 注意:PHP 的 checkin_list 会把时间字段换成当前视角,checkin_detail 不换(withAvatar 仅 list 为 true)
if withAvatar {
item["my_checkin_time"] = myTime
item["partner_checkin_time"] = partnerTime
}
orZero := func(v any) any {
if v == nil {
return 0
}
return v
}
orEmpty := func(v any) any {
if v == nil {
return ""
}
return v
}
// 与 PHP 逐行顺序赋值保持一致:非创建者视角下 my_* 先被覆盖,随后 partner_* 读到的是已覆盖后的值
// (PHP 原有行为,对方的 streak/照片/备注会显示为自己的值;为保持 API 一致这里原样复刻)
swap := func(my, partner string, def func(any) any) {
if isCreator {
item[my] = def(item[my])
item[partner] = def(item[partner])
} else {
item[my] = def(item[partner])
item[partner] = def(item[my])
}
}
swap("my_streak", "partner_streak", orZero)
swap("my_photo", "partner_photo", orEmpty)
swap("my_note", "partner_note", orEmpty)
if withAvatar {
ca, cn := orEmpty(item["creator_avatar"]), orEmpty(item["creator_name"])
if isCreator {
item["my_checkin_avatar"], item["my_checkin_name"] = ca, cn
item["partner_checkin_avatar"], item["partner_checkin_name"] = "", ""
} else {
item["my_checkin_avatar"], item["my_checkin_name"] = "", ""
item["partner_checkin_avatar"], item["partner_checkin_name"] = ca, cn
}
}
}
func checkinList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
if c.QHas("page") || c.QHas("page_size") {
return demoListResponse(c, "checkin", M{"week_both_count": 0, "checkin_log_total": 0})
}
return c.OK(M{"items": demoData("checkin"), "week_both_count": 0, "total": 5})
}
uid := c.UID()
suffix, window := listWindow(c)
items, err := c.DB.Query("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = c.id) ORDER BY c.sort_order ASC, c.created_at DESC, c.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...)
if err != nil {
return err
}
for _, it := range items {
decorateCheckin(it, uid, true)
}
total := countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = checkins.id)", ids[0], ids[1])
var logTotal int64
if cid := c.CoupleID(); cid != 0 {
logTotal = countC(c, "SELECT COUNT(*) as c FROM checkin_logs WHERE couple_id = ?", cid)
}
if c.QHas("page") || c.QHas("page_size") {
return listResponse(c, items, total, M{"week_both_count": 0, "checkin_log_total": logTotal})
}
return c.OK(M{"items": rows(items), "week_both_count": 0, "total": total, "checkin_log_total": logTotal})
}
func countC(c *httpapi.Ctx, sql string, args ...any) int64 {
r, _ := c.DB.QueryOne(sql, args...)
if r == nil {
return 0
}
return r.Int("c")
}
func checkinAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
if err := checkTextOr400(c, c.InTrim("title")); err != nil {
return err
}
id, err := c.DB.Insert("INSERT INTO checkins (couple_id, title, icon, type, frequency, is_completed, sort_order, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
cid, c.In("title"), inOr(c, "icon", "✅"), inOr(c, "type", "dual"), inOr(c, "frequency", "daily"), 0, inOr(c, "sort_order", 0), uid)
if err != nil {
return err
}
logActivity(c.DB, cid, uid, "checkin_create", "添加了打卡项「"+c.InStr("title")+"」", "")
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
// loadCheckinItem 查询打卡项(未绑定或不存在时已写出 404)
func loadCheckinItem(c *httpapi.Ctx, id int64) (store.Row, error) {
ids := coupleUserIds(c, 0)
var item store.Row
if len(ids) == 2 {
var err error
item, err = c.DB.QueryOne("SELECT * FROM checkins WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return nil, err
}
}
if item == nil {
return nil, c.Err(404, "打卡项不存在")
}
if err := guardSmokingCheckin(c, id); err != nil {
return nil, err
}
return item, nil
}
func checkinDo(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, uid := c.CoupleID(), c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少打卡ID")
}
item, err := loadCheckinItem(c, id)
if err != nil {
return err
}
isCreator := uid == item.Int("created_by")
td, nowS := today(), now()
freq := store.ToStr(item["frequency"])
if item.IsNull("frequency") {
freq = "daily"
}
typ := store.ToStr(item["type"])
if item.IsNull("type") {
typ = "both"
}
mon, ms := weekMonday(), monthStart()
if typ == "first" && !isCreator {
return c.Err(403, "第一视角打卡仅创建者可操作")
}
if typ == "her" && isCreator {
return c.Err(403, "她打卡仅对方可操作")
}
myTimeField, myStreakField, myPhotoField, myNoteField, myDoneField := "my_checkin_time", "my_streak", "my_photo", "my_note", "completed_by_me"
partnerTimeField := "partner_checkin_time"
if !isCreator {
myTimeField, myStreakField, myPhotoField, myNoteField, myDoneField = "partner_checkin_time", "partner_streak", "partner_photo", "partner_note", "completed_by_partner"
partnerTimeField = "my_checkin_time"
}
myLastTime := store.ToStr(item[myTimeField])
myLast, myHas := first10(item[myTimeField])
if store.Truthy(myLastTime) {
if t, err := parseDate(myLastTime); err == nil && time.Since(t) < 5*time.Second {
return c.JSON(429, M{"error": "操作太快,请稍后再试", "cooldown": true})
}
}
if periodDone(freq, myLast, myHas, td, mon, ms) {
return c.OK(M{"error": "本期已打卡", "already_done": true})
}
yesterday := daysAgo(1)
myStreak := int64(1)
if myHas && myLast == yesterday {
myStreak = item.Int(myStreakField) + 1
}
photo := inOr(c, "photo", "")
note := inOr(c, "note", "")
if err := checkTextOr400(c, store.ToStr(note)); err != nil {
return err
}
if _, err := c.DB.Exec(fmt.Sprintf("UPDATE checkins SET %s = 1, %s = ?, %s = ?, %s = ?, %s = ? WHERE id = ?", myDoneField, myTimeField, myStreakField, myPhotoField, myNoteField),
nowS, myStreak, photo, note, id); err != nil {
return err
}
allDone := true
if typ == "both" {
partnerLast, partnerHas := first10(item[partnerTimeField])
allDone = periodDone(freq, partnerLast, partnerHas, td, mon, ms)
}
if allDone {
c.DB.Exec("UPDATE checkins SET is_completed = 1, completed_at = ? WHERE id = ?", nowS, id)
}
title := item.Str("title")
actID := logActivity(c.DB, cid, uid, "checkin_do", "打卡「"+title+"」", "")
c.DB.Exec("INSERT INTO checkin_logs (checkin_id, couple_id, user_id, photo, note) VALUES (?, ?, ?, ?, ?)", id, cid, uid, photo, note)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "打卡「" + mbTruncate(title, 20) + "」",
"thing2": fmt.Sprintf("TA已完成打卡,坚持第%d天 💪", myStreak),
"date3": td,
"thing4": "点击查看详情",
}, "pages/punch/punch", actID, uid)
}
return c.OK(M{"success": true, "my_streak": myStreak, "both_done": allDone, "type": typ})
}
func checkinUncheck(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少打卡ID")
}
item, err := loadCheckinItem(c, id)
if err != nil {
return err
}
sql := "UPDATE checkins SET completed_by_me = 0, my_checkin_time = NULL, my_streak = 0, my_photo = '', my_note = '', is_completed = 0 WHERE id = ?"
if uid != item.Int("created_by") {
sql = "UPDATE checkins SET completed_by_partner = 0, partner_checkin_time = NULL, partner_streak = 0, partner_photo = '', partner_note = '', is_completed = 0 WHERE id = ?"
}
if _, err := c.DB.Exec(sql, id); err != nil {
return err
}
return c.OK(M{"success": true})
}
func checkinDetail(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK([]any{})
}
id := c.QInt("id")
if id == 0 {
return c.Err(400, "缺少打卡ID")
}
item, err := c.DB.QueryOne("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.id = ? AND c.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if item == nil {
return c.Err(404, "打卡项不存在")
}
uid := c.UID()
decorateCheckin(item, uid, false)
page, pageSize, offset := pageParams(c, 20, 100)
logs, err := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC, cl.id DESC LIMIT ? OFFSET ?", id, pageSize, offset)
if err != nil {
return err
}
// 统计覆盖全部记录,不能随列表分页改变。
stats, err := c.DB.Query(`SELECT user_id, COUNT(*) AS total,
SUM(CASE WHEN DATE(created_at) = ? THEN 1 ELSE 0 END) AS today_count,
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS week_count,
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS month_count,
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS year_count
FROM checkin_logs WHERE checkin_id = ? GROUP BY user_id`, today(), weekMonday(), monthStart(), yearStart(), id)
if err != nil {
return err
}
var total, myT, myW, myM, myY, pT, pW, pM, pY int64
for _, row := range stats {
total += row.Int("total")
if row.Int("user_id") == uid {
myT, myW, myM, myY = row.Int("today_count"), row.Int("week_count"), row.Int("month_count"), row.Int("year_count")
} else {
pT += row.Int("today_count")
pW += row.Int("week_count")
pM += row.Int("month_count")
pY += row.Int("year_count")
}
}
return c.OK(M{
"item": item, "logs": rows(logs), "total": total, "page": page, "page_size": pageSize, "has_more": offset+int64(len(logs)) < total,
"my_today": myT, "my_week": myW, "my_month": myM, "my_year": myY,
"partner_today": pT, "partner_week": pW, "partner_month": pM, "partner_year": pY,
})
}
func checkinStats(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
ids = []int64{c.UID(), 0}
}
td, ws := today(), weekMonday()
uid, partner := c.UID(), partnerUserId(c.DB, c.UID())
smokeCount := func(owner int64, since string) int64 {
return countC(c, "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0 AND created_at >= ?", owner, since)
}
return c.OK(M{
"week_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time >= ?", ids[0], ids[1], ws) + smokeCount(uid, ws),
"week_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND partner_checkin_time >= ?", ids[0], ids[1], ws) + smokeCount(partner, ws),
"week_both_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time >= ? AND partner_checkin_time >= ?", ids[0], ids[1], ws, ws),
"today_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time LIKE ?", ids[0], ids[1], td+"%") + smokeCount(uid, td),
"today_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND partner_checkin_time LIKE ?", ids[0], ids[1], td+"%") + smokeCount(partner, td),
})
}
func checkinToggle(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
id := c.In("id")
if err := guardSmokingCheckin(c, c.InInt("id")); err != nil {
return err
}
cur, _ := c.DB.QueryOne("SELECT is_completed FROM checkins WHERE id = ? AND couple_id = ?", id, cid)
newStatus := int64(1)
if cur != nil && store.Truthy(cur["is_completed"]) {
newStatus = 0
}
var completedAt any
if newStatus == 1 {
completedAt = now()
}
if _, err := c.DB.Exec("UPDATE checkins SET is_completed = ?, completed_at = ? WHERE id = ? AND couple_id = ?", newStatus, completedAt, id, cid); err != nil {
return err
}
title := ""
if it, _ := c.DB.QueryOne("SELECT title FROM checkins WHERE id = ?", id); it != nil {
title = it.Str("title")
}
verb := "取消了"
if newStatus == 1 {
verb = "完成了"
}
logActivity(c.DB, cid, c.UID(), "checkin_toggle", verb+"打卡「"+title+"」", "")
return c.OK(M{"success": true, "is_completed": newStatus})
}
func checkinDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
id := c.In("id")
if err := guardSmokingCheckin(c, c.InInt("id")); err != nil {
return err
}
c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id)
if _, err := c.DB.Exec("DELETE FROM checkins WHERE id = ? AND couple_id = ?", id, cid); err != nil {
return err
}
return c.OK(M{"success": true})
}
@@ -0,0 +1,44 @@
package handler
import (
"database/sql/driver"
"encoding/json"
"fmt"
"past/internal/testdb"
"strings"
"testing"
)
func TestCheckinDetailPaginatesWithoutTruncatingStats(t *testing.T) {
for _, page := range []int64{1, 2, 3, 4} {
t.Run(fmt.Sprint(page), func(t *testing.T) {
logs := [][]driver.Value{{int64(50 - page), int64(1), "2026-10-11 09:00:00"}}
if page == 4 {
logs = nil
}
c, out, _ := permissionCtx(t, "GET", fmt.Sprintf("/api/checkinDetail?id=9&page=%d&page_size=20", page), "", []testdb.Step{
uidStep(1), cidStep(10),
{Match: "SELECT user1_id, user2_id FROM couples", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(2)}}},
{Match: "c.created_by IN (?, ?)", Args: []any{int64(9), int64(1), int64(2)}, Columns: []string{"id", "created_by", "frequency", "type"}, Rows: [][]driver.Value{{int64(9), int64(1), "daily", "both"}}},
{Match: "ORDER BY cl.created_at DESC, cl.id DESC LIMIT ? OFFSET ?", Args: []any{int64(9), int64(20), (page - 1) * 20}, Columns: []string{"id", "user_id", "created_at"}, Rows: logs},
{Match: "GROUP BY user_id", Check: func(query string, args []any) error {
if strings.Contains(query, "LIMIT") {
return fmt.Errorf("stats must not be paginated")
}
return nil
}, Columns: []string{"user_id", "total", "today_count", "week_count", "month_count", "year_count"}, Rows: [][]driver.Value{{int64(1), int64(23), int64(1), int64(3), int64(7), int64(23)}, {int64(2), int64(18), int64(2), int64(4), int64(8), int64(18)}}},
})
checkinDetail(c)
if out.Code != 200 {
t.Fatalf("code=%d body=%s", out.Code, out.Body.String())
}
var got map[string]any
if err := json.Unmarshal(out.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["total"] != float64(41) || got["my_year"] != float64(23) || got["partner_year"] != float64(18) || got["has_more"] != (page < 3) {
t.Fatalf("unexpected response: %s", out.Body.String())
}
})
}
}
+424
View File
@@ -0,0 +1,424 @@
// Package handler 按模块实现全部 action,与 backend/api/index.php 一一对应。
package handler
import (
"bytes"
"encoding/json"
"fmt"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf16"
"unicode/utf8"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
type M = httpapi.M
// deps 注入的依赖(上传器等)
var up *upload.Uploader
// Register 注册全部 action
func Register(r *httpapi.Router, uploader *upload.Uploader) {
up = uploader
registerUser(r)
registerCouple(r)
registerTimeline(r)
registerAlbum(r)
registerDiary(r)
registerSongs(r)
registerCheckin(r)
registerPeriod(r)
registerBg(r)
registerSite(r)
registerActivity(r)
registerSubscribe(r)
registerPet(r)
registerAdmin(r)
}
func now() string { return time.Now().Format("2006-01-02 15:04:05") }
// needLoginRecord 对应“从未登录过,或距上次登录超过 5 分钟”
func needLoginRecord(lastLoginAt string) bool {
if lastLoginAt == "" {
return true
}
t, err := time.ParseInLocation("2006-01-02 15:04:05", lastLoginAt, time.Local)
return err != nil || time.Since(t) > 300*time.Second
}
func today() string { return time.Now().Format("2006-01-02") }
func daysAgo(n int) string { return time.Now().AddDate(0, 0, -n).Format("2006-01-02") }
// rows 保证列表非 nil(JSON 输出 [] 而非 null)
func rows(r []store.Row) []store.Row {
if r == nil {
return []store.Row{}
}
return r
}
// generateUserId 对应 generateUserId($db):520 开头顺序递增,记录在 settings.last_user_id
func generateUserId(db store.Querier) (string, error) {
row, err := db.QueryOne("SELECT `value` FROM settings WHERE `key` = 'last_user_id'")
if err != nil {
return "", err
}
var next int64
if row != nil && row.Int("value") >= 520001 {
next = row.Int("value") + 1
} else {
mx, err := db.QueryOne("SELECT MAX(CAST(user_id AS SIGNED)) as max_id FROM users WHERE user_id LIKE '520%'")
if err != nil {
return "", err
}
if mx != nil && mx.Int("max_id") != 0 {
next = mx.Int("max_id") + 1
} else {
next = 520001
}
}
if _, err := db.Exec("REPLACE INTO settings (`key`, `value`, updated_at) VALUES ('last_user_id', ?, CURRENT_TIMESTAMP)", fmt.Sprint(next)); err != nil {
return "", err
}
return fmt.Sprint(next), nil
}
// generateCoupleSpaceId 对应 'CP' . str_pad($coupleId, 6, '0', STR_PAD_LEFT)
func generateCoupleSpaceId(coupleID int64) string {
return fmt.Sprintf("CP%06d", coupleID)
}
// logActivity 对应 logActivity(),返回 activity id
func logActivity(db store.Querier, coupleID, userID int64, actionType, description, detail string) int64 {
id, _ := db.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
coupleID, userID, actionType, description, detail)
return id
}
// coupleUserIds 对应 getCoupleUserIds($coupleId):返回 [user1_id, user2_id],无则空
func coupleUserIds(c *httpapi.Ctx, coupleID int64) []int64 {
if coupleID == 0 {
coupleID = c.CoupleID()
if coupleID == 0 {
return nil
}
}
row, _ := c.DB.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", coupleID)
if row == nil {
return nil
}
return []int64{row.Int("user1_id"), row.Int("user2_id")}
}
// partnerUserId 对应 getPartnerUserId($userId);无则 0
func partnerUserId(db store.Querier, userID int64) int64 {
u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", userID)
if u == nil || u.Int("couple_id") == 0 {
return 0
}
cp, _ := db.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", u.Int("couple_id"))
if cp == nil {
return 0
}
if cp.Int("user1_id") == userID {
return cp.Int("user2_id")
}
return cp.Int("user1_id")
}
// userOpenId 对应 getUserOpenId($uid)
func userOpenId(db store.Querier, uid int64) string {
if uid == 0 {
return ""
}
row, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", uid)
if row == nil {
return ""
}
return row.Str("openid")
}
// executeUnbind 对应 executeUnbind($db, $cid):删除全部情侣数据与文件,仅保留用户资料
func executeUnbind(db store.Querier, cid int64) {
db.Exec("DELETE FROM timeline WHERE couple_id = ?", cid)
db.Exec("DELETE FROM diaries WHERE couple_id = ?", cid)
db.Exec("DELETE FROM songs WHERE couple_id = ?", cid)
db.Exec("DELETE FROM checkin_logs WHERE couple_id = ?", cid)
db.Exec("DELETE FROM checkins WHERE couple_id = ?", cid)
albums, _ := db.Query("SELECT id FROM albums WHERE couple_id = ?", cid)
for _, a := range albums {
photos, _ := db.Query("SELECT path FROM photos WHERE album_id = ?", a.Int("id"))
for _, p := range photos {
up.Remove(p.Str("path"))
}
db.Exec("DELETE FROM photos WHERE album_id = ?", a.Int("id"))
}
db.Exec("DELETE FROM albums WHERE couple_id = ?", cid)
db.Exec("DELETE FROM activity_log WHERE couple_id = ?", cid)
users, _ := db.Query("SELECT id FROM users WHERE couple_id = ?", cid)
if len(users) > 0 {
ids := make([]any, 0, len(users))
for _, u := range users {
ids = append(ids, u.Int("id"))
}
ph := placeholders(len(ids))
args := append(append([]any{}, ids...), ids...)
db.Exec("DELETE FROM invitations WHERE from_user_id IN ("+ph+") OR to_user_id IN ("+ph+")", args...)
db.Exec("DELETE FROM invite_links WHERE user_id IN ("+ph+")", ids...)
}
db.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid)
db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid)
sp, _ := db.QueryOne("SELECT couple_space_id FROM couples WHERE id = ?", cid)
if sp != nil && sp.Str("couple_space_id") != "" {
dir := filepath.Join(up.Dir, "couples", sp.Str("couple_space_id"))
if st, err := os.Stat(dir); err == nil && st.IsDir() {
os.RemoveAll(dir)
}
}
db.Exec("DELETE FROM couples WHERE id = ?", cid)
}
// createDefaultAlbums 对应 createDefaultAlbums()
func createDefaultAlbums(db store.Querier, coupleID, userID int64) {
defaults := [][2]string{
{"日常甜蜜", "我们的小日常"},
{"旅行记录", "一起走过的风景"},
{"美食打卡", "一起吃的每一顿饭"},
{"生活碎片", "生活中的点点滴滴"},
{"节日纪念", "特别的节日和纪念日"},
{"自拍合照", "我们的精彩瞬间"},
}
for i, d := range defaults {
db.Exec("INSERT INTO albums (couple_id, title, description, sort_order, created_by) VALUES (?, ?, ?, ?, ?)", coupleID, d[0], d[1], i, userID)
}
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// resolveLoginLocation 对应 resolveLoginLocation($input, $fallback)
func resolveLoginLocation(c *httpapi.Ctx, fallback string) string {
raw := strings.TrimSpace(c.InStr("location"))
if raw == "" {
raw = strings.TrimSpace(fallback)
}
if raw == "" {
return ""
}
if strings.Contains(raw, ",") {
parts := strings.Split(raw, ",")
if len(parts) == 2 && isNumeric(strings.TrimSpace(parts[0])) && isNumeric(strings.TrimSpace(parts[1])) {
lat := store.ToFloat(strings.TrimSpace(parts[0]))
lng := store.ToFloat(strings.TrimSpace(parts[1]))
if addr := c.App.Geo.ReverseGeocode(lat, lng); addr != "" {
return addr
}
}
}
return raw
}
// isNumeric 对应 PHP is_numeric
func isNumeric(s string) bool {
if s == "" {
return false
}
var f float64
_, err := fmt.Sscanf(s, "%g", &f)
if err != nil {
return false
}
// Sscanf 允许前缀匹配,再严格检查字符集
for _, ch := range s {
if !(ch >= '0' && ch <= '9') && ch != '.' && ch != '-' && ch != '+' && ch != 'e' && ch != 'E' {
return false
}
}
return true
}
// demoData 对应 getDemoData($module)
func demoData(module string) []M {
ts := now()
switch module {
case "timeline":
return []M{
{"id": -1, "title": "第一次相遇", "content": "还记得那天阳光正好,你穿着白色的衬衫,在图书馆的角落里安静地看书。", "event_date": daysAgo(30), "location": "图书馆", "images": []any{}, "icon": "❤️", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -2, "title": "第一次约会", "content": "一起看了场电影,吃了顿美味的晚餐,聊了很多很多。", "event_date": daysAgo(25), "location": "电影院", "images": []any{}, "icon": "🎬", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -3, "title": "一起做饭", "content": "第一次一起下厨,虽然把厨房弄得一团糟,但很开心。", "event_date": daysAgo(20), "location": "家", "images": []any{}, "icon": "🍳", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -4, "title": "公园散步", "content": "傍晚在公园散步,夕阳很美,你更美。", "event_date": daysAgo(15), "location": "公园", "images": []any{}, "icon": "🌅", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -5, "title": "纪念日", "content": "属于我们的特别日子,值得永远铭记。", "event_date": daysAgo(10), "location": "", "images": []any{}, "icon": "🎉", "sort_order": 0, "created_by": 0, "created_at": ts},
}
case "album":
return []M{
{"id": -1, "title": "甜蜜日常", "description": "记录我们的点点滴滴", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -2, "title": "旅行记忆", "description": "一起走过的风景", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -3, "title": "美食打卡", "description": "一起吃的每一顿饭", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -4, "title": "生活碎片", "description": "生活中的点点滴滴", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -5, "title": "我们的故事", "description": "从开始到现在", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
}
case "diary":
return []M{
{"id": -1, "title": "初识", "content": "今天在图书馆遇见了你,心跳加速的感觉真好。", "mood": "😊", "weather": "☀️", "diary_date": daysAgo(5), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -2, "title": "美好的一天", "content": "和你在一起的时间总是过得太快,期待下次见面。", "mood": "🥰", "weather": "☀️", "diary_date": daysAgo(4), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -3, "title": "小确幸", "content": "收到你的消息,一整天的心情都变好了。", "mood": "💕", "weather": "⛅", "diary_date": daysAgo(3), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -4, "title": "想念", "content": "今天没有见到你,好想你呀。", "mood": "😔", "weather": "🌧️", "diary_date": daysAgo(2), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -5, "title": "期待未来", "content": "想象和你一起的每一个明天,都是那么美好。", "mood": "💖", "weather": "☀️", "diary_date": daysAgo(1), "images": []any{}, "created_by": 0, "created_at": ts},
}
case "songs":
return []M{
{"id": -1, "song_name": "告白气球", "artist": "周杰伦", "note": "第一次约会时听的歌", "created_by": 0, "created_at": ts},
{"id": -2, "song_name": "小情歌", "artist": "苏打绿", "note": "你最喜欢的一首歌", "created_by": 0, "created_at": ts},
{"id": -3, "song_name": "想你的夜", "artist": "关喆", "note": "每次想你的时候都会听", "created_by": 0, "created_at": ts},
{"id": -4, "song_name": "有点甜", "artist": "汪苏泷", "note": "我们的主题曲", "created_by": 0, "created_at": ts},
{"id": -5, "song_name": "最美的期待", "artist": "周笔畅", "note": "对未来充满期待", "created_by": 0, "created_at": ts},
}
case "checkin":
return []M{
{"id": -1, "title": "每天说爱你", "icon": "❤️", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -2, "title": "一起运动", "icon": "🏃", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -3, "title": "早睡打卡", "icon": "🌙", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -4, "title": "喝水提醒", "icon": "💧", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -5, "title": "看书学习", "icon": "📚", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
}
}
return []M{}
}
// inOr 对应 $input['key'] ?? $def(null 也视为未设置)
func inOr(c *httpapi.Ctx, key string, def any) any {
if v := c.In(key); v != nil {
return v
}
return def
}
// mbTruncate 对应 mb_strlen($s) > n ? mb_substr($s, 0, n) . '...' : $s
func mbTruncate(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n]) + "..."
}
return s
}
// jsonEncode 对应 PHP json_encode($v) 默认标志(存库用):转义 "/" 为 "\/",非 ASCII 转 \uXXXX(含代理对),
// 使 Go 写入的 JSON 字段与 PHP 历史数据字节一致。
func jsonEncode(v any) string {
if v == nil {
return "null"
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return "null"
}
return phpEscapeJSON(strings.TrimRight(buf.String(), "\n"))
}
// phpEscapeJSON 把 Go 的 JSON 文本转换成 PHP json_encode 默认输出形式
func phpEscapeJSON(s string) string {
var sb strings.Builder
inStr := false
for i := 0; i < len(s); {
ch := s[i]
if !inStr {
if ch == '"' {
inStr = true
}
sb.WriteByte(ch)
i++
continue
}
switch {
case ch == '\\':
// 已转义序列(Go 产生的 \uXXXX / \" / \\ / \n 等)原样保留
if i+1 < len(s) {
sb.WriteByte(ch)
sb.WriteByte(s[i+1])
i += 2
} else {
sb.WriteByte(ch)
i++
}
case ch == '"':
inStr = false
sb.WriteByte(ch)
i++
case ch == '/':
sb.WriteString("\\/")
i++
case ch >= 0x80:
r, size := utf8.DecodeRuneInString(s[i:])
if r >= 0x10000 {
r1, r2 := utf16.EncodeRune(r)
fmt.Fprintf(&sb, "\\u%04x\\u%04x", r1, r2)
} else {
fmt.Fprintf(&sb, "\\u%04x", r)
}
i += size
default:
sb.WriteByte(ch)
i++
}
}
return sb.String()
}
// jsonDecodeList 对应 json_decode($s, true) ?: []:解析失败或为空返回空数组
func jsonDecodeList(v any) any {
s := store.ToStr(v)
if s == "" {
return []any{}
}
var out any
if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil {
return []any{}
}
switch x := out.(type) {
case []any:
if len(x) == 0 {
return []any{}
}
case map[string]any:
if len(x) == 0 {
return []any{}
}
case bool:
if !x {
return []any{}
}
case float64:
if x == 0 {
return []any{}
}
case string:
if x == "" || x == "0" {
return []any{}
}
}
return out
}
// countRaw 对应 $stmt->fetch()['cnt'] 原样输出(未经 intval,PHP 8.0 下为字符串)
func countRaw(c *httpapi.Ctx, col, sql string, args ...any) any {
r, _ := c.DB.QueryOne(sql, args...)
if r == nil {
return nil
}
return r[col]
}
// randInt 对应 random_int(min, max)
func randInt(min, max int) int { return min + rand.Intn(max-min+1) }
+611
View File
@@ -0,0 +1,611 @@
package handler
import (
"fmt"
"math"
"regexp"
"strings"
"time"
"past/internal/geo"
"past/internal/httpapi"
"past/internal/store"
)
var targetIDRe = regexp.MustCompile(`^[0-9]{6}$`)
func registerCouple(r *httpapi.Router) {
r.Handle("generate_invite_link", generateInviteLink)
r.Handle("check_invite_data_status", checkInviteDataStatus)
r.Handle("check_invite_code", checkInviteCode)
r.Handle("invite_by_phone", inviteByPhone)
r.Handle("invite_partner", invitePartner)
r.Handle("my_invitations", myInvitations)
r.Handle("respond_invitation", respondInvitation)
r.Handle("accept_share_invite", acceptShareInvite)
r.Handle("couple_info", coupleInfo)
r.Handle("update_couple_info", updateCoupleInfo)
r.Handle("unbind_request", unbindRequest)
r.Handle("unbind_respond", unbindRespond)
r.Handle("unbind_cancel", unbindCancel)
r.Handle("unbind_status", unbindStatus)
r.Handle("unbind_couple", unbindCouple)
}
// parseDate 对应 new DateTime($str)(仅支持常见格式)
func parseDate(s string) (time.Time, error) {
s = strings.TrimSpace(s)
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02", "2006/01/02", "2006-1-2", "2006.01.02"} {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("DateTime::__construct(): Failed to parse time string (%s)", s)
}
// diffDays 对应 DateTime::diff()->days(整天数,绝对值)
func diffDays(a, b time.Time) int64 {
d := a.Sub(b)
if d < 0 {
d = -d
}
return int64(math.Floor(d.Hours() / 24))
}
// birthdayCountdown 按日历日计算下一次生日。生日当天仍是 0 天,不会因为当天零点已过而跳到下一年。
func birthdayCountdown(nowT, bday time.Time, currentAge int64) (time.Time, int64, int64) {
today := time.Date(nowT.Year(), nowT.Month(), nowT.Day(), 0, 0, 0, 0, nowT.Location())
next := time.Date(today.Year(), bday.Month(), bday.Day(), 0, 0, 0, 0, nowT.Location())
if next.Before(today) {
next = next.AddDate(1, 0, 0)
}
// 用 UTC 中的日历日做差,避免当前时分秒导致“明天生日却显示 0 天”。
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
nextDate := time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, time.UTC)
daysLeft := int64(nextDate.Sub(todayDate) / (24 * time.Hour))
return next, daysLeft, currentAge + 1
}
func generateInviteLink(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if user != nil && user.Int("couple_id") != 0 {
return c.Err(400, "您已绑定情侣")
}
if ex, _ := c.DB.QueryOne("SELECT code FROM invite_links WHERE user_id = ? AND used = 0 ORDER BY created_at DESC LIMIT 1", uid); ex != nil {
return c.OK(M{"success": true, "code": ex.Str("code"), "url": "?invite=" + ex.Str("code")})
}
code := randomHex(8)
if _, err := c.DB.Exec("INSERT INTO invite_links (user_id, code) VALUES (?, ?)", uid, code); err != nil {
return err
}
return c.OK(M{"success": true, "code": code, "url": "?invite=" + code})
}
func checkInviteDataStatus(c *httpapi.Ctx) error {
uid := c.UID()
cp, _ := c.DB.QueryOne("SELECT c.id, c.data_deleted, c.status FROM couples c WHERE (c.user1_id = ? OR c.user2_id = ?) AND c.status = 'unbound' AND c.data_deleted = 0 LIMIT 1", uid, uid)
if cp != nil {
return c.OK(M{"has_pending_data": true, "msg": "请等待系统删除之前的情侣数据后再进行绑定操作"})
}
return c.OK(M{"has_pending_data": false})
}
func checkInviteCode(c *httpapi.Ctx) error {
code := strings.TrimSpace(c.Q("code"))
if code == "" {
return c.Err(400, "缺少邀请码")
}
link, err := c.DB.QueryOne("SELECT il.*, u.nickname, u.avatar FROM invite_links il JOIN users u ON il.user_id = u.id WHERE il.code = ? AND il.used = 0", code)
if err != nil {
return err
}
if link == nil {
return c.Err(404, "邀请链接无效或已使用")
}
// PHP 中 $link['inviter_couple_id'] ?? $link['couple_id'] 两个键都不存在,判断恒为假,此处保持一致
return c.OK(M{"success": true, "inviter": M{"nickname": link["nickname"], "avatar": link["avatar"]}})
}
func inviteByPhone(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
target := c.InTrim("username")
if !c.InHas("username") {
target = c.InTrim("phone")
}
message := c.InTrim("message")
if target == "" {
return c.Err(400, "请输入对方用户名")
}
if pass, msg := c.App.Wechat.CheckText(message, userOpenId(c.DB, uid)); !pass {
return c.Err(400, msg)
}
me, _ := c.DB.QueryOne("SELECT username, couple_id FROM users WHERE id = ?", uid)
if me != nil && me.Int("couple_id") != 0 {
return c.Err(400, "您已绑定情侣")
}
if me != nil && me.Str("username") == target {
return c.Err(400, "不能邀请自己")
}
t, _ := c.DB.QueryOne("SELECT id, username, couple_id FROM users WHERE username = ?", target)
if t == nil {
return c.Err(400, "该用户不存在")
}
if t.Int("couple_id") != 0 {
return c.Err(400, "对方已绑定情侣")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Int("id")); ex != nil {
return c.Err(400, "已发送过邀请,等待对方回应")
}
if _, err := c.DB.Exec("INSERT INTO invitations (from_user_id, to_user_id, to_phone, message, type) VALUES (?, ?, ?, ?, 'username')", uid, t.Int("id"), target, message); err != nil {
return err
}
return c.OK(M{"success": true, "msg": "邀请已发送"})
}
func invitePartner(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
targetID := c.InTrim("target_user_id")
message := c.InTrim("message")
if targetID == "" {
return c.Err(400, "请输入对方ID")
}
if !targetIDRe.MatchString(targetID) {
return c.Err(400, "ID格式不正确,请输入6位数字ID")
}
me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender FROM users WHERE id = ?", uid)
if me != nil && me.Int("couple_id") != 0 {
return c.Err(400, "您已绑定情侣")
}
if me != nil && me.Str("user_id") == targetID {
return c.Err(400, "不能绑定自己")
}
if ex, _ := c.DB.QueryOne("SELECT c.id FROM couples c WHERE (c.user1_id = ? OR c.user2_id = ?) AND c.status = 'unbound' AND c.data_deleted = 0 LIMIT 1", uid, uid); ex != nil {
return c.Err(400, "请等待系统删除之前的情侣数据后再进行绑定操作")
}
t, _ := c.DB.QueryOne("SELECT id, user_id, couple_id, nickname, gender FROM users WHERE user_id = ?", targetID)
if t == nil {
return c.Err(400, "该ID不存在,请确认对方ID是否正确")
}
if t.Int("couple_id") != 0 {
return c.Err(400, "对方已绑定情侣")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Int("id")); ex != nil {
return c.Err(400, "已发送过邀请,等待对方回应")
}
id, err := c.DB.Insert("INSERT INTO invitations (from_user_id, to_user_id, message, type) VALUES (?, ?, ?, 'id')", uid, t.Int("id"), message)
if err != nil {
return err
}
return c.OK(M{"success": true, "invitation_id": fmt.Sprint(id), "msg": "邀请已发送,等待对方同意"})
}
func myInvitations(c *httpapi.Ctx) error {
uid := c.UID()
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
var coupleID int64
if user != nil {
coupleID = user.Int("couple_id")
}
received, err := c.DB.Query("SELECT i.*, u.username as from_username, u.nickname as from_nickname, u.avatar as from_avatar FROM invitations i JOIN users u ON i.from_user_id = u.id WHERE i.to_user_id = ? ORDER BY i.created_at DESC", uid)
if err != nil {
return err
}
sent, err := c.DB.Query("SELECT i.*, u.username as to_username, u.nickname as to_nickname, u.avatar as to_avatar FROM invitations i JOIN users u ON i.to_user_id = u.id WHERE i.from_user_id = ? ORDER BY i.created_at DESC", uid)
if err != nil {
return err
}
links, err := c.DB.Query("SELECT * FROM invite_links WHERE user_id = ? ORDER BY created_at DESC", uid)
if err != nil {
return err
}
return c.OK(M{"received": rows(received), "sent": rows(sent), "links": rows(links), "couple_id": coupleID})
}
// bindCouple 创建情侣关系(事务):couples 记录、空间 ID、双方 couple_id;返回 coupleId
func bindCouple(c *httpapi.Ctx, user1, user2 int64, extra func(tx *store.Tx) error) (int64, error) {
tx, err := c.DB.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", user1, user2)
if err != nil {
return 0, err
}
if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", generateCoupleSpaceId(coupleID), coupleID); err != nil {
return 0, err
}
if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, user1, user2); err != nil {
return 0, err
}
if extra != nil {
if err := extra(tx); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
ensureUserDirs(user1)
ensureUserDirs(user2)
return coupleID, nil
}
func respondInvitation(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
invID := c.InInt("invitation_id")
accept := c.InBool("accept")
inv, err := c.DB.QueryOne("SELECT * FROM invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'", invID, uid)
if err != nil {
return err
}
if inv == nil {
return c.Err(404, "邀请不存在或已处理")
}
fromID := inv.Int("from_user_id")
rs, _ := c.DB.Query("SELECT couple_id FROM users WHERE id IN (?, ?)", uid, fromID)
for _, r := range rs {
if r.Int("couple_id") != 0 {
return c.Err(400, "已绑定情侣,无法接受")
}
}
if accept {
coupleID, err := bindCouple(c, fromID, uid, func(tx *store.Tx) error {
_, err := tx.Exec("UPDATE invitations SET status = 'accepted', responded_at = CURRENT_TIMESTAMP WHERE id = ?", invID)
return err
})
if err != nil {
return err
}
createDefaultAlbums(c.DB, coupleID, uid)
// 通知邀请人:对方已同意
actID := logActivity(c.DB, coupleID, uid, "couple_bind", "你们已成功绑定为情侣", "")
c.App.Wechat.SendSubscribeMessage(fromID, "notify", map[string]string{
"thing1": "我们在一起啦 💕",
"thing2": "TA 已同意你的邀请",
"date3": today(),
"thing4": "点击开始记录",
}, "pages/index/index", actID, uid)
return c.OK(M{"success": true, "couple_id": fmt.Sprint(coupleID)})
}
c.DB.Exec("UPDATE invitations SET status = 'rejected', responded_at = CURRENT_TIMESTAMP WHERE id = ?", invID)
return c.OK(M{"success": true, "msg": "已拒绝邀请"})
}
func acceptShareInvite(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
inviterDisplayID := c.InTrim("inviter_id")
if inviterDisplayID == "" {
return c.Err(400, "缺少邀请人ID")
}
me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender, nickname FROM users WHERE id = ?", uid)
if me != nil && me.Int("couple_id") != 0 {
return c.Err(400, "您已绑定情侣")
}
inviter, _ := c.DB.QueryOne("SELECT id, user_id, couple_id, gender, nickname FROM users WHERE user_id = ?", inviterDisplayID)
if inviter == nil {
return c.Err(400, "邀请人不存在")
}
if inviter.Int("couple_id") != 0 {
return c.Err(400, "邀请人已绑定情侣")
}
if me != nil && me.Str("user_id") == inviter.Str("user_id") {
return c.Err(400, "不能绑定自己")
}
if me != nil && me.Str("gender") != "" && inviter.Str("gender") != "" && me.Str("gender") == inviter.Str("gender") {
return c.Err(400, "双方性别一致,仅支持异性绑定")
}
inviterID := inviter.Int("id")
existing, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE ((from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)) AND status = 'pending'", uid, inviterID, inviterID, uid)
if existing != nil {
c.DB.Exec("UPDATE invitations SET status = 'accepted', responded_at = CURRENT_TIMESTAMP WHERE id = ?", existing.Int("id"))
}
coupleID, err := bindCouple(c, uid, inviterID, func(tx *store.Tx) error {
if existing == nil {
_, err := tx.Exec("INSERT INTO invitations (from_user_id, to_user_id, status, responded_at, type) VALUES (?, ?, 'accepted', CURRENT_TIMESTAMP, 'share')", inviterID, uid)
return err
}
return nil
})
if err != nil {
return err
}
createDefaultAlbums(c.DB, coupleID, uid)
// 通知邀请人:对方已接受分享邀请
actID := logActivity(c.DB, coupleID, uid, "couple_bind", "你们已成功绑定为情侣", "")
c.App.Wechat.SendSubscribeMessage(inviterID, "notify", map[string]string{
"thing1": "我们在一起啦 💕",
"thing2": "TA 已接受你的分享邀请",
"date3": today(),
"thing4": "点击开始记录",
}, "pages/index/index", actID, uid)
return c.OK(M{"success": true, "couple_id": fmt.Sprint(coupleID), "partner_nickname": inviter["nickname"]})
}
func coupleInfo(c *httpapi.Ctx) error {
cid := c.CoupleID()
uid := c.UID()
if cid == 0 {
user, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, created_at FROM users WHERE id = ?", uid)
regDate := today()
if user != nil {
ca := user.Str("created_at")
if len(ca) >= 10 {
regDate = ca[:10]
} else {
regDate = ca
}
}
var days int64
if regDate != "" {
t, err := parseDate(regDate)
if err != nil {
return err
}
days = diffDays(time.Now(), t)
}
var p1 any
if user != nil {
p1 = user
}
return c.OK(M{"not_bound": true, "partner1": p1, "partner2": nil, "love_start_date": regDate, "days_together": days})
}
info, err := c.DB.QueryOne("SELECT * FROM couples WHERE id = ?", cid)
if err != nil {
return err
}
if info == nil {
info = store.Row{}
}
partners, err := c.DB.Query("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, region, location, lat, lng FROM users WHERE couple_id = ? ORDER BY id ASC", cid)
if err != nil {
return err
}
info["partner1"] = nil
info["partner2"] = nil
if len(partners) > 0 {
info["partner1"] = partners[0]
}
if len(partners) > 1 {
info["partner2"] = partners[1]
}
info["distance"] = nil
if len(partners) >= 2 {
lat1, lng1 := store.ToFloat(partners[0]["lat"]), store.ToFloat(partners[0]["lng"])
lat2, lng2 := store.ToFloat(partners[1]["lat"]), store.ToFloat(partners[1]["lng"])
if lat1 != 0 && lng1 != 0 && lat2 != 0 && lng2 != 0 {
info["distance"] = geo.Haversine(lat1, lng1, lat2, lng2)
}
}
var partner store.Row
for _, p := range partners {
if p.Int("id") != uid {
partner = p
}
}
if partner != nil && partner.Str("birthday") != "" {
bday, err := parseDate(partner.Str("birthday"))
if err != nil {
return err
}
next, daysLeft, ageNext := birthdayCountdown(time.Now(), bday, partner.Int("age"))
info["partner_birthday_countdown"] = M{
"birthday": partner["birthday"],
"next_date": next.Format("2006-01-02"),
"days_left": daysLeft,
"age_next": ageNext,
}
}
if store.Truthy(info["love_start_date"]) {
start, err := parseDate(info.Str("love_start_date"))
if err != nil {
return err
}
info["days_together"] = diffDays(start, time.Now())
} else {
info["days_together"] = 0
}
return c.OK(info)
}
func updateCoupleInfo(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := c.CoupleID()
if cid == 0 {
return c.Err(400, "请先绑定情侣")
}
uid := c.UID()
if pass, msg := c.App.Wechat.CheckText(c.InTrim("love_story"), userOpenId(c.DB, uid)); !pass {
return c.Err(400, msg)
}
if _, err := c.DB.Exec("UPDATE couples SET love_start_date = ?, love_story = ? WHERE id = ?", c.InStr("love_start_date"), c.InStr("love_story"), cid); err != nil {
return err
}
if store.Truthy(c.InStr("love_start_date")) {
logActivity(c.DB, cid, uid, "couple_edit", "设置了恋爱纪念日", "")
}
return c.OK(M{"success": true})
}
func userCoupleID(c *httpapi.Ctx, uid int64) int64 {
u, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if u == nil {
return 0
}
return u.Int("couple_id")
}
// withBoundCouple serializes all lifecycle transitions with the expiry worker.
func withBoundCouple(c *httpapi.Ctx, next func(*store.Tx, int64) error) error {
cid := c.CoupleID()
if cid == 0 {
return c.Err(400, "未绑定情侣")
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
cp, err := tx.QueryOne("SELECT id,status FROM couples WHERE id=? FOR UPDATE", cid)
if err != nil {
return err
}
member, err := tx.QueryOne("SELECT id FROM users WHERE id=? AND couple_id=?", c.UID(), cid)
if err != nil {
return err
}
if cp == nil || cp.Str("status") != "active" || member == nil {
return c.Err(409, "情侣关系已变化,请刷新")
}
if err := next(tx, cid); err != nil {
return err
}
return tx.Commit()
}
func unbindRequest(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
err := withBoundCouple(c, func(tx *store.Tx, cid int64) error {
if _, err := tx.Exec("UPDATE unbind_requests SET status='expired',responded_at=NOW() WHERE couple_id=? AND status='pending' AND created_at<=DATE_SUB(NOW(),INTERVAL 72 HOUR)", cid); err != nil {
return err
}
existing, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND status IN ('pending','cooling') FOR UPDATE", cid)
if err != nil {
return err
}
if existing != nil {
return c.Err(409, "已有解绑申请或正在冷静期")
}
_, err = tx.Exec("INSERT INTO unbind_requests (couple_id,requester_id,status) VALUES (?,?,'pending')", cid, c.UID())
return err
})
if err != nil {
return err
}
return c.OK(M{"success": true, "msg": "请求已发送,对方同意后进入72小时冷静期"})
}
func unbindRespond(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
agree := c.InBool("agree")
err := withBoundCouple(c, func(tx *store.Tx, cid int64) error {
req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND status='pending' AND requester_id!=? AND created_at>DATE_SUB(NOW(),INTERVAL 72 HOUR) FOR UPDATE", cid, c.UID())
if err != nil {
return err
}
if req == nil {
return c.Err(409, "没有有效的待确认申请")
}
if agree {
_, err = tx.Exec("UPDATE unbind_requests SET status='cooling',responded_at=NOW(),cooling_until=DATE_ADD(NOW(),INTERVAL 72 HOUR) WHERE id=?", req.Int("id"))
} else {
_, err = tx.Exec("UPDATE unbind_requests SET status='rejected',responded_at=NOW() WHERE id=?", req.Int("id"))
}
return err
})
if err != nil {
return err
}
return c.OK(M{"success": true, "msg": "申请已处理,双方同意后进入72小时冷静期"})
}
func unbindCancel(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
err := withBoundCouple(c, func(tx *store.Tx, cid int64) error {
req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND ((status='pending' AND requester_id=? AND created_at>DATE_SUB(NOW(),INTERVAL 72 HOUR)) OR (status='cooling' AND cooling_until>NOW())) ORDER BY id DESC LIMIT 1 FOR UPDATE", cid, c.UID())
if err != nil {
return err
}
if req == nil {
return c.Err(409, "没有可取消的申请,或冷静期已结束")
}
_, err = tx.Exec("UPDATE unbind_requests SET status='cancelled',cancelled_at=NOW(),cancelled_by=? WHERE id=?", c.UID(), req.Int("id"))
return err
})
if err != nil {
return err
}
return c.OK(M{"success": true, "msg": "已取消解绑,关系继续保留"})
}
func unbindStatus(c *httpapi.Ctx) error {
if err := c.DB.AdvanceUnbindings(); err != nil {
return err
}
cid := c.CoupleID()
uid := c.UID()
if cid == 0 {
cp, err := c.DB.QueryOne("SELECT id FROM couples WHERE user1_id=? OR user2_id=? ORDER BY id DESC LIMIT 1", uid, uid)
if err != nil {
return err
}
if cp != nil {
cid = cp.Int("id")
}
}
if cid == 0 {
return c.OK(M{"status": "none", "couple_id": nil})
}
if _, err := c.DB.Exec("UPDATE unbind_requests SET status='expired',responded_at=NOW() WHERE couple_id=? AND status='pending' AND created_at<=DATE_SUB(NOW(),INTERVAL 72 HOUR)", cid); err != nil {
return err
}
req, err := c.DB.QueryOne("SELECT id,requester_id,status,created_at,responded_at,cooling_until,completed_at,cancelled_at,cancelled_by,GREATEST(0,TIMESTAMPDIFF(SECOND,NOW(),CASE WHEN status='cooling' THEN cooling_until ELSE DATE_ADD(created_at,INTERVAL 72 HOUR) END)) AS remaining_seconds FROM unbind_requests WHERE couple_id=? ORDER BY id DESC LIMIT 1", cid)
if err != nil {
return err
}
if req == nil {
return c.OK(M{"status": "none", "couple_id": cid})
}
req["is_requester"] = req.Int("requester_id") == uid
req["couple_id"] = cid
req["data_deleted"] = 0
if req.Str("status") == "completed" {
cp, err := c.DB.QueryOne("SELECT data_deleted FROM couples WHERE id = ?", cid)
if err != nil {
return err
}
if cp != nil {
req["data_deleted"] = cp.Int("data_deleted")
}
}
owner, err := c.DB.QueryOne("SELECT nickname FROM users WHERE id=?", req.Int("requester_id"))
if err != nil {
return err
}
if owner != nil {
req["requester_name"] = owner.Str("nickname")
}
return c.OK(req)
}
func unbindCouple(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
if err := c.DB.AdvanceUnbindings(); err != nil {
return err
}
if c.CoupleID() != 0 {
return c.Err(409, "冷静期结束后将自动解绑,不能提前解除")
}
return c.OK(M{"success": true})
}
@@ -0,0 +1,16 @@
package handler
import "testing"
func TestInvitationUserIDFormat(t *testing.T) {
for _, id := range []string{"520087", "521685", "523019", "999999"} {
if !targetIDRe.MatchString(id) {
t.Errorf("valid six-digit ID rejected: %s", id)
}
}
for _, id := range []string{"", "52301", "5230190", "52301a", "523019 ", "523019"} {
if targetIDRe.MatchString(id) {
t.Errorf("invalid ID accepted: %q", id)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package handler
import (
"testing"
"time"
)
func TestBirthdayCountdown(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*60*60)
tests := []struct {
name string
now time.Time
birthday time.Time
wantDate string
wantDays int64
wantAge int64
}{
{"birthday today", time.Date(2026, 9, 19, 18, 30, 0, 0, loc), time.Date(2000, 9, 19, 0, 0, 0, 0, loc), "2026-09-19", 0, 27},
{"birthday tomorrow", time.Date(2026, 9, 19, 23, 59, 0, 0, loc), time.Date(2000, 9, 20, 0, 0, 0, 0, loc), "2026-09-20", 1, 27},
{"birthday passed", time.Date(2026, 9, 19, 10, 0, 0, 0, loc), time.Date(2000, 1, 2, 0, 0, 0, 0, loc), "2027-01-02", 105, 27},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next, days, age := birthdayCountdown(tt.now, tt.birthday, 26)
if got := next.Format("2006-01-02"); got != tt.wantDate || days != tt.wantDays || age != tt.wantAge {
t.Fatalf("got date=%s days=%d age=%d; want date=%s days=%d age=%d", got, days, age, tt.wantDate, tt.wantDays, tt.wantAge)
}
})
}
}
+344
View File
@@ -0,0 +1,344 @@
package handler
import (
"fmt"
"regexp"
"strings"
"past/internal/httpapi"
"past/internal/store"
)
// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://past.sundynix.cn/backend/'),MEDIA_BASE 未定义
const mediaBase = "https://past.sundynix.cn/backend/"
var tagRe = regexp.MustCompile(`<[^>]*>`)
// stripTags 近似 PHP strip_tags
func stripTags(s string) string { return tagRe.ReplaceAllString(s, "") }
// mbSubstr 对应 mb_substr($s, 0, n)
func mbSubstr(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
// avatarURL 对应评论里的 avatar_url 拼接
func avatarURL(avatar string) string {
if avatar == "" {
return ""
}
if strings.HasPrefix(avatar, "http") {
return avatar
}
return mediaBase + avatar
}
// diaryDateOrFallback 对应 PHP !empty($input['diary_date']) 的日期默认逻辑。
// 缺失、null、空字符串和纯空白都不允许进入 diary_date NOT NULL 列。
func diaryDateOrFallback(value any, fallback string) string {
if date := strings.TrimSpace(store.ToStr(value)); date != "" {
return date
}
return fallback
}
func registerDiary(r *httpapi.Router) {
r.Handle("diary_list", diaryList)
r.Handle("diary_add", diaryAdd)
r.Handle("diary_update", diaryUpdate)
r.Handle("diary_delete", diaryDelete)
r.Handle("diary_detail", diaryDetail)
r.Handle("diary_comment_list", diaryCommentList)
r.Handle("diary_comment_add", diaryCommentAdd)
r.Handle("diary_comment_delete", diaryCommentDelete)
}
func diaryList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
demo := demoData("diary")
return c.OK(M{"items": demo, "total": len(demo)})
}
page := c.QInt("page")
if !c.QHas("page") {
page = 1
}
if page < 1 {
page = 1
}
pageSize := int64(10)
if c.QHas("page_size") {
pageSize = c.QInt("page_size")
}
if pageSize < 1 {
pageSize = 1
}
if pageSize > 50 {
pageSize = 50
}
offset := (page - 1) * pageSize
cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM diaries WHERE created_by IN (?, ?)", ids[0], ids[1])
if err != nil {
return err
}
uid := c.UID()
items, err := c.DB.Query(`SELECT d.*,
(SELECT COUNT(*) FROM diary_comments WHERE diary_id = d.id) as comment_count,
(SELECT COUNT(*) FROM diary_likes WHERE diary_id = d.id) as like_count,
(SELECT COUNT(*) FROM diary_favorites WHERE diary_id = d.id) as favorite_count,
(SELECT COUNT(*) FROM diary_likes WHERE diary_id = d.id AND user_id = ?) > 0 as is_liked,
(SELECT COUNT(*) FROM diary_favorites WHERE diary_id = d.id AND user_id = ?) > 0 as is_favorited
FROM diaries d WHERE d.created_by IN (?, ?) ORDER BY d.diary_date DESC, d.created_at DESC LIMIT ? OFFSET ?`,
uid, uid, ids[0], ids[1], pageSize, offset)
if err != nil {
return err
}
for _, it := range items {
it["images"] = jsonDecodeList(it["images"])
it["is_liked"] = store.Truthy(it["is_liked"])
it["is_favorited"] = store.Truthy(it["is_favorited"])
}
return c.OK(M{"items": rows(items), "total": cnt["cnt"], "page": page, "page_size": pageSize})
}
func diaryAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
diaryDate := diaryDateOrFallback(c.In("diary_date"), today())
images := inOr(c, "images", []any{})
title := inOr(c, "title", "")
content := inOr(c, "content", "")
titleS, contentS := store.ToStr(title), store.ToStr(content)
region := c.InTrim("region")
lat, lng := c.InFloat("lat"), c.InFloat("lng")
location := c.InTrim("location")
if lat != 0 && lng != 0 && location == "" {
location = c.App.Geo.ReverseGeocode(lat, lng)
}
if err := checkTextOr400(c, strings.TrimSpace(titleS+" "+contentS)); err != nil {
return err
}
id, err := c.DB.Insert("INSERT INTO diaries (couple_id, title, content, mood, weather, diary_date, images, is_anniversary, region, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, title, content, inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, jsonEncode(images), inOr(c, "is_anniversary", 0), region, lat, lng, location, uid)
if err != nil {
return err
}
imgCount := 0
if l, ok := images.([]any); ok {
imgCount = len(l)
}
desc := "发表了日记"
if store.Truthy(titleS) {
desc += "「" + titleS + "」"
}
if imgCount > 0 {
desc += fmt.Sprintf(",上传了%d张图片", imgCount)
}
actID := logActivity(c.DB, cid, uid, "diary_create", desc, "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
displayTitle := "一篇新日记"
if store.Truthy(titleS) {
displayTitle = titleS
}
displayTitle = mbTruncate(displayTitle, 20)
displayContent := "TA写了一些心里话"
if store.Truthy(contentS) {
displayContent = mbSubstr(stripTags(contentS), 50)
}
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": displayTitle, "thing2": displayContent, "date3": today(), "thing4": "快去看看吧 💕",
}, "pages/diary/diary", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
func diaryUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
images := inOr(c, "images", []any{})
// 旧版小程序的编辑页不传 diary_date。空值时保留数据库原日期;
// 若历史上已被临时改成可空且存在 NULL,则回填当天,保证不再写入 NULL。
diaryDate := strings.TrimSpace(c.InStr("diary_date"))
dateExpr := "COALESCE(NULLIF(?, ''), NULLIF(diary_date, ''), ?)"
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
return err
}
if c.InHas("lat") {
_, err = c.DB.Exec("UPDATE diaries SET title = ?, content = ?, mood = ?, weather = ?, diary_date = "+dateExpr+", images = ?, is_anniversary = ?, region = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("title"), inOr(c, "content", ""), inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, today(), jsonEncode(images), inOr(c, "is_anniversary", 0), c.InTrim("region"), c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), c.In("id"), ids[0], ids[1])
} else {
_, err = c.DB.Exec("UPDATE diaries SET title = ?, content = ?, mood = ?, weather = ?, diary_date = "+dateExpr+", images = ?, is_anniversary = ?, region = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("title"), inOr(c, "content", ""), inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, today(), jsonEncode(images), inOr(c, "is_anniversary", 0), c.InTrim("region"), c.In("id"), ids[0], ids[1])
}
if err != nil {
return err
}
return c.OK(M{"success": true})
}
func diaryDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if _, err := c.DB.Exec("DELETE FROM diaries WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil {
return err
}
return c.OK(M{"success": true})
}
func countOf(c *httpapi.Ctx, sql string, args ...any) int64 {
r, _ := c.DB.QueryOne(sql, args...)
if r == nil {
return 0
}
return r.Int("cnt")
}
func diaryDetail(c *httpapi.Ctx) error {
id := c.QInt("id")
if id == 0 {
return c.Err(400, "缺少日记ID")
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
uid := c.UID()
item, err := c.DB.QueryOne("SELECT d.*, u.nickname as creator_name, u.avatar as creator_avatar FROM diaries d LEFT JOIN users u ON d.created_by = u.id WHERE d.id = ? AND d.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if item == nil {
return c.Err(404, "日记不存在")
}
item["images"] = jsonDecodeList(item["images"])
item["comment_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_comments WHERE diary_id = ?", id)
if _, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ?", id); err != nil {
return err
}
item["like_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ?", id)
item["favorite_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_favorites WHERE diary_id = ?", id)
item["is_liked"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ? AND user_id = ?", id, uid) > 0
item["is_favorited"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_favorites WHERE diary_id = ? AND user_id = ?", id, uid) > 0
return c.OK(item)
}
func diaryCommentList(c *httpapi.Ctx) error {
diaryID := c.QInt("diary_id")
if diaryID == 0 {
return c.Err(400, "缺少日记ID")
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
suffix, window := listWindow(c)
comments, err := c.DB.Query("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.diary_id = ? AND c.couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?)) ORDER BY c.created_at DESC, c.id DESC"+suffix, append([]any{diaryID, ids[0], ids[1]}, window...)...)
if err != nil {
return err
}
for _, cm := range comments {
cm["avatar_url"] = avatarURL(cm.Str("avatar"))
}
return listResponse(c, comments, countC(c, "SELECT COUNT(*) as c FROM diary_comments WHERE diary_id=? AND couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?))", diaryID, ids[0], ids[1]), nil)
}
func diaryCommentAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
if uid == 0 {
return c.Err(401, "请先登录")
}
diaryID := c.InInt("diary_id")
content := c.InTrim("content")
if diaryID == 0 {
return c.Err(400, "缺少日记ID")
}
if !store.Truthy(content) {
return c.Err(400, "请输入评论内容")
}
if err := checkTextOr400(c, content); err != nil {
return err
}
diary, _ := c.DB.QueryOne("SELECT id, created_by FROM diaries WHERE id = ? AND couple_id = ?", diaryID, cid)
if diary == nil {
return c.Err(404, "日记不存在")
}
parentID := c.InInt("parent_id")
var replyToUserID any
if c.InHas("reply_to_user_id") {
replyToUserID = c.InInt("reply_to_user_id")
}
commentID, err := c.DB.Insert("INSERT INTO diary_comments (diary_id, couple_id, user_id, content, parent_id, reply_to_user_id, reply_to_username) VALUES (?, ?, ?, ?, ?, ?, ?)",
diaryID, cid, uid, content, parentID, replyToUserID, c.InTrim("reply_to_username"))
if err != nil {
return err
}
comment, _ := c.DB.QueryOne("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.id = ?", commentID)
var out any = false
if comment != nil {
comment["avatar_url"] = avatarURL(comment.Str("avatar"))
out = comment
}
// 通知对象:优先被回复的人,否则日记作者;自己评论自己不通知
var notifyTo int64
if replyToUserID != nil {
notifyTo = store.ToInt(replyToUserID)
}
if notifyTo == 0 {
notifyTo = diary.Int("created_by")
}
if notifyTo != 0 && notifyTo != uid {
actID := logActivity(c.DB, cid, uid, "diary_comment_add", "评论了你的日记", "")
c.App.Wechat.SendSubscribeMessage(notifyTo, "notify", map[string]string{
"thing1": "日记有新评论 💬",
"thing2": mbTruncate("TA评论了你:"+content, 20),
"date3": today(),
"thing4": "点击查看详情",
}, "pages/diary/diary", actID, uid)
}
return c.OK(M{"success": true, "comment": out})
}
func diaryCommentDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
if uid == 0 {
return c.Err(401, "请先登录")
}
commentID := c.InInt("id")
if commentID == 0 {
return c.Err(400, "缺少评论ID")
}
if _, err := c.DB.Exec("DELETE FROM diary_comments WHERE id = ? AND user_id = ?", commentID, uid); err != nil {
return err
}
return c.OK(M{"success": true})
}
+24
View File
@@ -0,0 +1,24 @@
package handler
import "testing"
func TestDiaryDateOrFallback(t *testing.T) {
tests := []struct {
name string
value any
fallback string
want string
}{
{"missing", nil, "2026-09-19", "2026-09-19"},
{"empty", "", "2026-09-19", "2026-09-19"},
{"whitespace", " ", "2026-09-19", "2026-09-19"},
{"provided", " 2026-09-18 ", "2026-09-19", "2026-09-18"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := diaryDateOrFallback(tt.value, tt.fallback); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
+15
View File
@@ -0,0 +1,15 @@
package handler
import "testing"
// 期望值来自 PHP 8.2: echo json_encode(["uploads/x.jpg","中文😊","a\"b\\c\n<>&"]);
func TestPhpEscapeJSON(t *testing.T) {
got := jsonEncode([]any{"uploads/x.jpg", "中文😊", "a\"b\\c\n<>&"})
want := "[\"uploads\\/x.jpg\",\"\\u4e2d\\u6587\\ud83d\\ude0a\",\"a\\\"b\\\\c\\n<>&\"]"
if got != want {
t.Fatalf("got %s want %s", got, want)
}
if jsonDecodeList(got).([]any)[1] != "中文😊" {
t.Fatalf("round-trip failed")
}
}
@@ -0,0 +1,47 @@
package handler
import (
"past/internal/httpapi"
"past/internal/store"
)
// Keep legacy response shapes for released clients without pagination parameters.
func listWindow(c *httpapi.Ctx) (string, []any) {
if !c.QHas("page") && !c.QHas("page_size") {
return "", nil
}
_, size, offset := pageParams(c, 20, 100)
return " LIMIT ? OFFSET ?", []any{size, offset}
}
func listResponse(c *httpapi.Ctx, items []store.Row, total int64, extra M) error {
if !c.QHas("page") && !c.QHas("page_size") {
return c.OK(rows(items))
}
page, size, offset := pageParams(c, 20, 100)
result := M{"items": rows(items), "total": total, "page": page, "page_size": size, "has_more": offset+int64(len(items)) < total}
for k, v := range extra {
result[k] = v
}
return c.OK(result)
}
func demoListResponse(c *httpapi.Ctx, module string, extra M) error {
demo := demoData(module)
items := make([]store.Row, len(demo))
for i, item := range demo {
items[i] = store.Row(item)
}
total := int64(len(items))
if c.QHas("page") || c.QHas("page_size") {
_, size, offset := pageParams(c, 20, 100)
if offset >= total {
items = nil
} else {
end := offset + size
if end > total {
end = total
}
items = items[offset:end]
}
}
return listResponse(c, items, total, extra)
}
@@ -0,0 +1,71 @@
package handler
import (
"database/sql/driver"
"encoding/json"
"past/internal/httpapi"
"past/internal/testdb"
"strings"
"testing"
)
func TestPagedListsBoundQueriesAndKeepMemberScope(t *testing.T) {
for _, tt := range []struct {
name, uri, table string
handler httpapi.HandlerFunc
args []any
extra []testdb.Step
}{
{"songs", "/api/songsList", "songs", songsList, []any{int64(1), int64(3), int64(20), int64(20)}, nil},
{"albums", "/api/albumList", "albums", albumList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "FROM photos p JOIN albums a", Columns: []string{"c"}, Rows: [][]driver.Value{{int64(83)}}}}},
{"photos", "/api/photos?album_id=9", "photos", photos, []any{int64(1), int64(3), "9", int64(20), int64(20)}, nil},
{"timeline", "/api/timelineList", "timeline", timelineList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "event_date >= CURRENT_DATE", Columns: []string{"id", "event_date"}, Rows: [][]driver.Value{{int64(50), "2026-12-01"}}}}},
{"checkins", "/api/checkinList", "checkins", checkinList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "FROM checkin_logs WHERE couple_id = ?", Args: []any{int64(22)}, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(90)}}}}},
{"comments", "/api/diaryCommentList?diary_id=9", "diary_comments", diaryCommentList, []any{int64(9), int64(1), int64(3), int64(20), int64(20)}, nil},
} {
t.Run(tt.name, func(t *testing.T) {
uri := tt.uri
if strings.Contains(uri, "?") {
uri += "&"
} else {
uri += "?"
}
uri += "page=2&page_size=20"
steps := []testdb.Step{uidStep(1), cidStep(22), {Match: "SELECT user1_id, user2_id FROM couples", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(3)}}},
{Match: "LIMIT ? OFFSET ?", Args: tt.args, Check: func(q string, _ []any) error {
if !strings.Contains(q, "IN (?, ?)") {
t.Errorf("missing member predicate: %s", q)
}
return nil
}, Columns: []string{"id"}, Rows: [][]driver.Value{{int64(21)}}},
{Match: "SELECT COUNT(*) as c FROM " + tt.table, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(41)}}}}
steps = append(steps, tt.extra...)
c, out, _ := permissionCtx(t, "GET", uri, "", steps)
tt.handler(c)
if out.Code != 200 {
t.Fatalf("status %d: %s", out.Code, out.Body.String())
}
var result map[string]any
if err := json.Unmarshal(out.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result["total"] != float64(41) || result["page"] != float64(2) || result["has_more"] != true {
t.Fatalf("wrong pagination: %s", out.Body.String())
}
if len(result["items"].([]any)) != 1 {
t.Fatal("wrong items")
}
if tt.name == "albums" && result["photo_total"] != float64(83) {
t.Fatal("photo total must cover all albums")
}
})
}
}
func TestListPaginationCapsPageSizeAndClampsPage(t *testing.T) {
c, _, _ := permissionCtx(t, "GET", "/api/list?page=-1&page_size=9999", "", nil)
suffix, args := listWindow(c)
if suffix != " LIMIT ? OFFSET ?" || args[0] != int64(100) || args[1] != int64(0) {
t.Fatalf("unsafe window %s %v", suffix, args)
}
}
+297
View File
@@ -0,0 +1,297 @@
package handler
import (
"math"
"strings"
"time"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
// ========== 通用上传 / 背景图 ==========
func registerBg(r *httpapi.Router) {
r.Handle("upload", uploadGeneric)
r.Handle("get_bg_config", getBgConfig)
r.Handle("set_bg_image", setBgImage)
r.Handle("delete_bg_image", deleteBgImage)
}
// uploadResult 统一处理 handleUpload 结果的响应(成功/违规/失败)
func uploadResult(c *httpapi.Ctx, res upload.Result, onSuccess func(path string) error) error {
if res.Path != "" {
return onSuccess(res.Path)
}
if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
}
return c.ErrCode(400, "上传失败: "+orDefault(res.Error, "未知错误"), upload.ErrHandleFailed)
}
func uploadGeneric(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
subDir := "photos"
if c.FormHas("sub_dir") {
subDir = c.Form("sub_dir")
}
if strings.ContainsAny(subDir, "/\\.") || subDir == "" {
return c.Err(400, "无效的上传目录")
}
uid := c.UID()
if uid == 0 {
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
}
prefix := upload.UserFilePath(uid, subDir)
var res *upload.Result
for _, f := range []string{"file", "avatar", "photo", "image"} {
if fh := c.File(f); fh != nil {
r := up.Handle(fh, "", prefix)
res = &r
break
}
}
if res == nil {
// 兼容某些 Android 端:原始请求体
if raw := c.Body(); len(raw) > 100 {
r := up.HandleBytes(raw, "upload_"+time.Now().Format("20060102150405")+".jpg", "", prefix)
res = &r
}
}
if res == nil {
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
}
return uploadResult(c, *res, func(path string) error {
return c.OK(M{"success": true, "path": path})
})
}
func settingValue(c *httpapi.Ctx, key string) (string, bool) {
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
if row == nil {
return "", false
}
return row.Str("value"), true
}
func setSetting(c *httpapi.Ctx, key string, value any) error {
_, err := c.DB.Exec("INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?", key, value, value)
return err
}
func bgKeys(cid int64) (string, string) {
if cid != 0 {
return "bg_" + store.ToStr(cid), "bgver_" + store.ToStr(cid)
}
return "bg_global", "bgver_global"
}
func getBgConfig(c *httpapi.Ctx) error {
bgKey, verKey := bgKeys(c.CoupleID())
img, _ := settingValue(c, bgKey)
ver, _ := settingValue(c, verKey)
return c.OK(M{"image": img, "version": store.ToInt(ver)})
}
func setBgImage(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
cid := c.CoupleID()
if cid == 0 {
return c.ErrCode(400, "请先绑定情侣", upload.ErrNotBound)
}
couplePath := upload.CouplePath(coupleSpaceID(c, cid), "covers")
bgKey, verKey := bgKeys(cid)
nextVersion := func() int64 {
if old, ok := settingValue(c, verKey); ok {
return store.ToInt(old) + 1
}
return 1
}
saveBg := func(path string, version int64) error {
if err := setSetting(c, bgKey, path); err != nil {
return err
}
if err := setSetting(c, verKey, version); err != nil {
return err
}
return c.OK(M{"success": true, "path": path, "version": version})
}
fh := c.File("bg")
if fh == nil {
fh = c.File("file")
}
if fh != nil {
version := nextVersion()
return uploadResult(c, up.Handle(fh, "", couplePath), func(path string) error { return saveBg(path, version) })
}
if raw := c.Body(); len(raw) > 100 {
res := up.SaveRaw(raw, "bg_", "jpg", "", couplePath)
if res.Path != "" {
return saveBg(res.Path, nextVersion())
}
if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
}
return c.ErrCode(400, "原始数据保存失败", upload.ErrRawBodySave)
}
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
}
func deleteBgImage(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
bgKey, verKey := bgKeys(cid)
if v, ok := settingValue(c, bgKey); ok && v != "" {
up.Remove(v)
}
if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = ? OR `key` = ?", bgKey, verKey); err != nil {
return err
}
return c.OK(M{"success": true})
}
// ========== 站点公开 ==========
func registerSite(r *httpapi.Router) {
r.Handle("site_info", siteInfo)
r.Handle("site_stats", siteStats)
}
func siteInfo(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT `key`, `value` FROM settings")
if err != nil {
return err
}
if len(rs) == 0 {
return c.OK([]any{}) // PHP 空数组 → []
}
out := M{}
for _, r := range rs {
out[r.Str("key")] = r["value"]
}
return c.OK(out)
}
func siteStats(c *httpapi.Ctx) error {
raw := func(t string) any { return countRaw(c, "cnt", "SELECT COUNT(*) as cnt FROM "+t) }
cnt := func(t string) int64 { return countOf(c, "SELECT COUNT(*) as cnt FROM "+t) }
couples, users := raw("couples"), raw("users")
memories := cnt("timeline") + cnt("photos") + cnt("diaries")
var days any = 0
if e, _ := c.DB.QueryOne("SELECT MIN(created_at) as d FROM users"); e != nil && e.Str("d") != "" {
if t, err := parseDate(e.Str("d")); err == nil {
d := math.Floor(time.Since(t).Seconds() / 86400)
if d < 1 {
d = 1
}
days = d
}
}
return c.OK(M{"couples": couples, "users": users, "memories": memories, "days": days})
}
// ========== 操作记录 / 订阅 ==========
func registerActivity(r *httpapi.Router) {
r.Handle("activity_log_list", activityLogList)
}
func registerSubscribe(r *httpapi.Router) {
r.Handle("subscribe_template", subscribeTemplate)
r.Handle("unsubscribe_template", unsubscribeTemplate)
r.Handle("my_subscribe_templates", mySubscribeTemplates)
}
// pageParams 对应 max(1, intval(page)) 与 min(maxSize, max(1, intval(page_size)))
func pageParams(c *httpapi.Ctx, defSize, maxSize int64) (page, pageSize, offset int64) {
page = 1
if c.QHas("page") {
page = c.QInt("page")
}
if page < 1 {
page = 1
}
pageSize = defSize
if c.QHas("page_size") {
pageSize = c.QInt("page_size")
}
if pageSize < 1 {
pageSize = 1
}
if pageSize > maxSize {
pageSize = maxSize
}
return page, pageSize, (page - 1) * pageSize
}
func activityLogList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK([]any{})
}
page, pageSize, offset := pageParams(c, 6, 100)
totalRaw := countRaw(c, "cnt", "SELECT COUNT(*) as cnt FROM activity_log WHERE user_id IN (?, ?)", ids[0], ids[1])
total := store.ToInt(totalRaw)
items, err := c.DB.Query("SELECT al.*, u.nickname, u.avatar, u.gender FROM activity_log al JOIN users u ON al.user_id = u.id WHERE al.user_id IN (?, ?) ORDER BY al.created_at DESC LIMIT ? OFFSET ?", ids[0], ids[1], pageSize, offset)
if err != nil {
return err
}
return c.OK(M{"items": rows(items), "total": totalRaw, "page": page, "page_size": pageSize, "total_pages": math.Ceil(float64(total) / float64(pageSize))})
}
func subscribeTemplate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
templateType, templateID := c.InTrim("template_type"), c.InTrim("template_id")
if !store.Truthy(templateType) || !store.Truthy(templateID) {
return c.Err(400, "缺少参数")
}
if configured, ok := c.App.Wechat.Templates()[templateType]; !ok || configured == "" || configured != templateID {
return c.Err(400, "订阅模板与服务端配置不一致")
}
if _, err := c.DB.Exec("INSERT INTO subscribe_templates (user_id, template_id, template_type) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE template_id = VALUES(template_id)", uid, templateID, templateType); err != nil {
return err
}
return c.OK(M{"success": true})
}
func unsubscribeTemplate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
templateType := c.InTrim("template_type")
if !store.Truthy(templateType) {
return c.Err(400, "缺少参数")
}
if _, err := c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", c.UID(), templateType); err != nil {
return err
}
return c.OK(M{"success": true})
}
func mySubscribeTemplates(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT template_type, template_id FROM subscribe_templates WHERE user_id = ?", c.UID())
if err != nil {
return err
}
if len(rs) == 0 {
return c.OK(M{"templates": []any{}}) // PHP 空数组 → []
}
out := M{}
for _, r := range rs {
out[r.Str("template_type")] = r["template_id"]
}
return c.OK(M{"templates": out})
}
+558
View File
@@ -0,0 +1,558 @@
package handler
import (
"fmt"
"strings"
"past/internal/httpapi"
"past/internal/store"
)
func registerPeriod(r *httpapi.Router) {
r.Handle("period_list", periodList)
r.Handle("period_add", periodProtected(periodAdd))
r.Handle("period_update", periodProtected(periodUpdate))
r.Handle("period_delete", periodProtected(periodDelete))
r.Handle("period_visible_get", periodVisibleGet)
r.Handle("period_visible_set", periodVisibleSet)
r.Handle("period_source_select", periodSourceSelect)
r.Handle("period_daily_save", periodProtected(periodDailySave))
r.Handle("period_daily_get", periodProtected(periodDailyGet))
r.Handle("period_sex_add", periodProtected(periodSexAdd))
r.Handle("period_sex_delete", periodProtected(periodSexDelete))
r.Handle("period_sex_list", periodProtected(periodSexList))
}
func periodProtected(next httpapi.HandlerFunc) httpapi.HandlerFunc {
return func(c *httpapi.Ctx) error {
if err := requirePeriodAccess(c); err != nil {
return err
}
if required, _, _, err := periodSourceState(c); err != nil {
return err
} else if required {
return c.Err(409, "请先选择姨姨记录来源")
}
return next(c)
}
}
// cidOrNil 对应 PHP 中直接把 getCoupleId()(可能为 null)绑定进 SQL 的场景:未绑定时传 NULL 而非 0
func cidOrNil(c *httpapi.Ctx) any {
if cid := c.CoupleID(); cid != 0 {
return cid
}
return nil
}
// coupleCond 根据 cid(可能为 nil)构造 couple_id 过滤条件与参数
func coupleCond(cid any) (string, []any) {
if cid == nil {
return "couple_id IS NULL", nil
}
return "couple_id = ?", []any{cid}
}
// 每人独立控制自己是否向伴侣开放同一份姨姨数据。
func periodVisible(c *httpapi.Ctx, uid int64) int64 {
row, _ := c.DB.QueryOne("SELECT period_visible FROM users WHERE id = ?", uid)
if row == nil {
return 0
}
return row.Int("period_visible")
}
func emptyPeriod(visible int64) M {
return M{"cycles": []any{}, "daily": []any{}, "sex_records": []any{}, "period_visible": visible, "can_access": false}
}
type periodAccess struct {
uid, cid, partnerID int64
visible, partnerVisible int64
}
func currentPeriodAccess(c *httpapi.Ctx) periodAccess {
uid := c.UID()
cid := c.CoupleID()
access := periodAccess{uid: uid, cid: cid, visible: periodVisible(c, uid), partnerVisible: 1}
if cid != 0 {
access.partnerVisible = 0
partner, _ := c.DB.QueryOne("SELECT id, period_visible FROM users WHERE couple_id = ? AND id != ? LIMIT 1", cid, uid)
if partner != nil {
access.partnerID = partner.Int("id")
access.partnerVisible = partner.Int("period_visible")
}
}
return access
}
func (a periodAccess) canAccess() bool {
return a.partnerVisible != 0
}
func requirePeriodAccess(c *httpapi.Ctx) error {
if !currentPeriodAccess(c).canAccess() {
return c.Err(403, "对方已关闭姨姨可见权限")
}
return nil
}
func periodList(c *httpapi.Ctx) error {
access := currentPeriodAccess(c)
if !access.canAccess() {
return c.OK(emptyPeriod(access.visible))
}
selectionRequired, me, partner, err := periodSourceState(c)
if err != nil {
return err
}
if selectionRequired {
myPreview, err := periodSourcePreview(c.DB, me)
if err != nil {
return err
}
partnerPreview, err := periodSourcePreview(c.DB, partner)
if err != nil {
return err
}
return c.OK(M{"cycles": []any{}, "daily": []any{}, "sex_records": []any{},
"period_visible": access.visible, "can_access": true, "has_couple": true,
"selection_required": true, "my_user_id": me, "partner_user_id": partner,
"my_preview": myPreview, "partner_preview": partnerPreview})
}
// 姨姨在绑定后仍是一份情侣数据;未绑定时只读取自己的记录。
var where string
var args []any
if access.cid != 0 {
where = "couple_id = ?"
args = []any{access.cid}
} else {
where = "couple_id IS NULL AND user_id = ?"
args = []any{access.uid}
}
cycles, err := c.DB.Query("SELECT * FROM period_records WHERE "+where+" ORDER BY start_date DESC", args...)
if err != nil {
return err
}
daily, err := c.DB.Query("SELECT * FROM period_daily_records WHERE "+where+" ORDER BY record_date DESC", args...)
if err != nil {
return err
}
sex, err := c.DB.Query("SELECT * FROM period_sex_records WHERE "+where+" ORDER BY record_date DESC, record_time ASC", args...)
if err != nil {
return err
}
return c.OK(M{"cycles": rows(cycles), "daily": rows(daily), "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != 0})
}
func periodAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
if !store.Truthy(startDate) || !store.Truthy(endDate) {
return c.Err(400, "请选择日期")
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil {
return err
}
cycleLen, periodLen := int64(28), int64(5)
if c.InHas("cycle_length") {
cycleLen = c.InInt("cycle_length")
}
if c.InHas("period_length") {
periodLen = c.InInt("period_length")
}
recID, err := c.DB.Insert("INSERT INTO period_records (couple_id, user_id, start_date, end_date, cycle_length, period_length, symptoms, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
cid, uid, startDate, endDate, cycleLen, periodLen, inOr(c, "symptoms", ""), inOr(c, "note", ""))
if err != nil {
return err
}
// 区间内每天创建 period_daily_records(对应 DatePeriod,含结束日)
start, err := parseDate(startDate)
if err != nil {
return err
}
end, err := parseDate(endDate)
if err != nil {
return err
}
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, d.Format("2006-01-02")); err != nil {
return err
}
}
actID, err := logActivityErr(c, cid, uid, "period_add", "记录了经期("+startDate+" ~ "+endDate+")")
if err != nil {
return err
}
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
if periodVisible(c, uid) != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "经期记录有更新",
"thing2": fmt.Sprintf("TA记录了经期(%d天),记得多关心哦 🌸", periodLen),
"date3": startDate,
"thing4": "点击查看详情",
}, "pages/period/period", actID, uid)
}
}
// PHP 返回的是 logActivity 之后的 lastInsertId(activity_log 的 id);Go 返回真实记录 id
return c.OK(M{"success": true, "id": fmt.Sprint(recID)})
}
// logActivityErr 与 logActivity 相同,但把 SQL 错误返回(对应 PHP 中会抛异常导致 500 的调用点)。
// 未绑定情侣(coupleID 为 nil)时跳过日志:activity_log.couple_id 为 NOT NULL,未绑定时无从归属。
func logActivityErr(c *httpapi.Ctx, coupleID any, userID int64, actionType, description string) (int64, error) {
if coupleID == nil {
return 0, nil
}
return c.DB.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
coupleID, userID, actionType, description, "")
}
func periodUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少记录ID")
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil {
return err
}
cycleLen, periodLen := int64(28), int64(5)
if c.InHas("cycle_length") {
cycleLen = c.InInt("cycle_length")
}
if c.InHas("period_length") {
periodLen = c.InInt("period_length")
}
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
start, err := parseDate(startDate)
if err != nil {
return c.Err(400, "开始日期无效")
}
end, err := parseDate(endDate)
if err != nil || end.Before(start) {
return c.Err(400, "结束日期无效")
}
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
oldArgs := append([]any{id}, condArgs...)
old, err := tx.QueryOne("SELECT start_date, end_date FROM period_records WHERE id = ? AND "+cond+" FOR UPDATE", oldArgs...)
if err != nil {
return err
}
if old == nil {
return c.Err(404, "周期记录不存在")
}
args := []any{startDate, endDate, cycleLen, periodLen, inOr(c, "symptoms", ""), inOr(c, "note", ""), id}
args = append(args, condArgs...)
if _, err := tx.Exec("UPDATE period_records SET start_date = ?, end_date = ?, cycle_length = ?, period_length = ?, symptoms = ?, note = ? WHERE id = ? AND "+cond, args...); err != nil {
return err
}
// 同步日历标记:去掉旧区间中不再属于任何周期的日期,再标记新区间。
oldStart, err := parseDate(old.Str("start_date"))
if err != nil {
return err
}
oldEnd, err := parseDate(old.Str("end_date"))
if err != nil {
return err
}
for d := oldStart; !d.After(oldEnd); d = d.AddDate(0, 0, 1) {
if !d.Before(start) && !d.After(end) {
continue
}
date := d.Format("2006-01-02")
checkArgs := append(append([]any{}, condArgs...), date, date)
other, err := tx.QueryOne("SELECT id FROM period_records WHERE "+cond+" AND start_date <= ? AND end_date >= ? LIMIT 1", checkArgs...)
if err != nil {
return err
}
if other == nil {
clearArgs := append(append([]any{}, condArgs...), date)
if _, err := tx.Exec("UPDATE period_daily_records SET period = '0' WHERE "+cond+" AND record_date = ?", clearArgs...); err != nil {
return err
}
}
}
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
date := d.Format("2006-01-02")
if cid == nil {
// MySQL 的 UNIQUE 索引允许多个 NULL;未绑定记录必须按用户和日期查找。
existing, err := tx.QueryOne("SELECT id FROM period_daily_records WHERE couple_id IS NULL AND user_id = ? AND record_date = ? LIMIT 1", uid, date)
if err != nil {
return err
}
if existing != nil {
if _, err := tx.Exec("UPDATE period_daily_records SET period = '1' WHERE couple_id IS NULL AND user_id = ? AND record_date = ?", uid, date); err != nil {
return err
}
continue
}
}
if _, err := tx.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, date); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return c.OK(M{"success": true})
}
func periodDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
id := c.InInt("id")
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
if id != 0 {
delArgs := append([]any{id}, condArgs...)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil {
return err
}
} else if store.Truthy(startDate) && store.Truthy(endDate) {
delArgs := append(append([]any{}, condArgs...), startDate, endDate)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE "+cond+" AND start_date = ? AND end_date = ?", delArgs...); err != nil {
return err
}
}
if store.Truthy(startDate) && store.Truthy(endDate) {
updArgs := append(append([]any{}, condArgs...), startDate, endDate)
if _, err := c.DB.Exec("UPDATE period_daily_records SET period = '0' WHERE "+cond+" AND record_date >= ? AND record_date <= ?", updArgs...); err != nil {
return err
}
}
if _, err := logActivityErr(c, cid, uid, "period_delete", "删除了经期周期记录"); err != nil {
return err
}
return c.OK(M{"success": true})
}
func periodVisibleGet(c *httpapi.Ctx) error {
access := currentPeriodAccess(c)
return c.OK(M{"period_visible": access.visible, "can_access": access.canAccess(), "has_couple": access.cid != 0})
}
func periodVisibleSet(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
visible := c.InInt("period_visible")
if visible != 0 && visible != 1 {
return c.Err(400, "可见权限只能为 0 或 1")
}
if _, err := c.DB.Exec("UPDATE users SET period_visible = ? WHERE id = ?", visible, c.UID()); err != nil {
return err
}
return c.OK(M{"success": true, "period_visible": visible})
}
func periodDailySave(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
recordDate := c.InTrim("record_date")
if !store.Truthy(recordDate) {
return c.Err(400, "请选择日期")
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("mood")+" "+c.InStr("discharge")+" "+c.InStr("note"))); err != nil {
return err
}
// $input['temperature'] !== '' && !== null ? floatval(...) : null
numOrNil := func(key string) any {
v := c.In(key)
if v == nil || v == "" {
return nil
}
return store.ToFloat(v)
}
values := []any{store.ToStr(inOr(c, "period", "0")), store.ToStr(inOr(c, "sex", "0")),
inOr(c, "symptoms", ""), inOr(c, "mood", ""), inOr(c, "discharge", ""),
numOrNil("temperature"), numOrNil("weight"), inOr(c, "note", ""), inOr(c, "period_detail", "")}
if cid == nil {
// UNIQUE(record_date, couple_id) 对 NULL 不生效;按本人和日期更新已有行。
existing, err := c.DB.QueryOne("SELECT id FROM period_daily_records WHERE couple_id IS NULL AND user_id = ? AND record_date = ? LIMIT 1", uid, recordDate)
if err != nil {
return err
}
if existing != nil {
args := append(append([]any{}, values...), uid, recordDate)
if _, err := c.DB.Exec("UPDATE period_daily_records SET period = ?, sex = ?, symptoms = ?, mood = ?, discharge = ?, temperature = ?, weight = ?, note = ?, period_detail = ?, updated_at = NOW() WHERE couple_id IS NULL AND user_id = ? AND record_date = ?", args...); err != nil {
return err
}
} else {
args := append([]any{nil, uid, recordDate}, values...)
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", args...); err != nil {
return err
}
}
} else {
args := append([]any{cid, uid, recordDate}, values...)
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE period = VALUES(period), sex = VALUES(sex), symptoms = VALUES(symptoms), mood = VALUES(mood), discharge = VALUES(discharge), temperature = VALUES(temperature), weight = VALUES(weight), note = VALUES(note), period_detail = VALUES(period_detail), updated_at = NOW()", args...); err != nil {
return err
}
}
// 若本次将月经标记设为无,检查所属周期是否还有月经记录
np := inOr(c, "period", "0")
if np == "0" || np == int64(0) || np == "" {
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
cycArgs := append(append([]any{}, condArgs...), recordDate, recordDate)
cycles, err := c.DB.Query("SELECT id, start_date, end_date FROM period_records WHERE "+cond+" AND start_date <= ? AND end_date >= ?", cycArgs...)
if err != nil {
return err
}
for _, cy := range cycles {
cntArgs := append(append([]any{}, condArgs...), cy["start_date"], cy["end_date"])
cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM period_daily_records WHERE "+cond+" AND record_date >= ? AND record_date <= ? AND period > 0", cntArgs...)
if err != nil {
return err
}
if cnt != nil && cnt.Int("cnt") == 0 {
delArgs := append([]any{cy.Int("id")}, condArgs...)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil {
return err
}
}
}
}
return c.OK(M{"success": true})
}
func periodDailyGet(c *httpapi.Ctx) error {
cid := cidOrNil(c)
uid := c.UID()
date := strings.TrimSpace(c.Q("record_date"))
if !store.Truthy(date) {
return c.Err(400, "请选择日期")
}
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
args := append(append([]any{}, condArgs...), date)
rec, err := c.DB.QueryOne("SELECT * FROM period_daily_records WHERE "+cond+" AND record_date = ?", args...)
if err != nil {
return err
}
if rec == nil {
return c.OK([]any{})
}
return c.OK(rec)
}
func periodSexAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
recordDate := c.InTrim("record_date")
if !store.Truthy(recordDate) {
return c.Err(400, "请选择日期")
}
recordTime := c.InTrim("record_time")
measure := c.InTrim("measure")
measureCustom := c.InTrim("measure_custom")
if err := checkTextOr400(c, measureCustom); err != nil {
return err
}
var rt any
if store.Truthy(recordTime) {
rt = recordTime
}
recID, err := c.DB.Insert("INSERT INTO period_sex_records (couple_id, user_id, record_date, record_time, measure, measure_custom) VALUES (?, ?, ?, ?, ?, ?)",
cid, uid, recordDate, rt, measure, measureCustom)
if err != nil {
return err
}
desc := "记录了亲密行为"
if store.Truthy(recordTime) {
desc += "(" + recordDate + " " + recordTime + ")"
}
actID, err := logActivityErr(c, cid, uid, "period_sex_add", desc)
if err != nil {
return err
}
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
if periodVisible(c, uid) != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "亲密记录有更新", "thing2": "TA新增了一条亲密记录 💕", "date3": recordDate, "thing4": "点击查看详情",
}, "pages/period/period", actID, uid)
}
}
return c.OK(M{"success": true, "id": fmt.Sprint(recID)})
}
func periodSexDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少记录ID")
}
if cid == nil {
if _, err := c.DB.Exec("DELETE FROM period_sex_records WHERE id = ? AND couple_id IS NULL AND user_id = ?", id, uid); err != nil {
return err
}
} else {
if _, err := c.DB.Exec("DELETE FROM period_sex_records WHERE id = ? AND couple_id = ?", id, cid); err != nil {
return err
}
}
return c.OK(M{"success": true})
}
func periodSexList(c *httpapi.Ctx) error {
cid := cidOrNil(c)
uid := c.UID()
date := strings.TrimSpace(c.Q("record_date"))
if !store.Truthy(date) {
return c.Err(400, "请选择日期")
}
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
args := append(append([]any{}, condArgs...), date)
items, err := c.DB.Query("SELECT * FROM period_sex_records WHERE "+cond+" AND record_date = ? ORDER BY record_time ASC, id ASC", args...)
if err != nil {
return err
}
return c.OK(rows(items))
}
+147
View File
@@ -0,0 +1,147 @@
package handler
import (
"fmt"
"past/internal/httpapi"
"past/internal/store"
)
// 绑定前的姨姨记录可能双方都有。只允许一份成为情侣的当前记录;
// 在用户选择前保留两份,选择时在同一事务中删除另一份并迁入当前情侣空间。
func periodSourceState(c *httpapi.Ctx) (required bool, me, partner int64, err error) {
cid, uid := c.CoupleID(), c.UID()
if cid == 0 {
return false, uid, 0, nil
}
couple, err := c.DB.QueryOne("SELECT user1_id, user2_id, period_source_user_id FROM couples WHERE id = ?", cid)
if err != nil || couple == nil {
return false, 0, 0, err
}
if couple.Int("user1_id") == uid {
partner = couple.Int("user2_id")
} else if couple.Int("user2_id") == uid {
partner = couple.Int("user1_id")
} else {
return false, 0, 0, fmt.Errorf("账号不属于当前情侣")
}
if couple.Int("period_source_user_id") != 0 {
return false, uid, partner, nil
}
myCount, err := periodSourceCount(c.DB, uid)
if err != nil {
return false, 0, 0, err
}
partnerCount, err := periodSourceCount(c.DB, partner)
if err != nil {
return false, 0, 0, err
}
if myCount > 0 && partnerCount > 0 {
return true, uid, partner, nil
}
if myCount > 0 {
err = selectPeriodSource(c, uid)
} else if partnerCount > 0 {
err = selectPeriodSource(c, partner)
}
return false, uid, partner, err
}
func periodSourceCount(db store.Querier, uid int64) (int64, error) {
row, err := db.QueryOne(`SELECT
(SELECT COUNT(*) FROM period_records WHERE user_id = ? AND couple_id IS NULL) +
(SELECT COUNT(*) FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL) +
(SELECT COUNT(*) FROM period_sex_records WHERE user_id = ? AND couple_id IS NULL) AS n`,
uid, uid, uid)
if err != nil {
return 0, err
}
return row.Int("n"), nil
}
func periodSourcePreview(db store.Querier, uid int64) (M, error) {
row, err := db.QueryOne("SELECT COUNT(*) AS cycles, MAX(start_date) AS latest FROM period_records WHERE user_id = ? AND couple_id IS NULL", uid)
if err != nil {
return nil, err
}
total, err := periodSourceCount(db, uid)
if err != nil {
return nil, err
}
return M{"cycles": row.Int("cycles"), "latest": row.Str("latest"), "items": total}, nil
}
func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error {
cid := c.CoupleID()
if cid == 0 {
return c.Err(400, "未绑定伴侣")
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
couple, err := tx.QueryOne("SELECT user1_id, user2_id, period_source_user_id FROM couples WHERE id = ? FOR UPDATE", cid)
if err != nil {
return err
}
if couple == nil || (keepUserID != couple.Int("user1_id") && keepUserID != couple.Int("user2_id")) {
return c.Err(400, "记录来源不是当前情侣成员")
}
if existing := couple.Int("period_source_user_id"); existing != 0 {
if existing == keepUserID {
return nil
}
return c.Err(409, "姨姨记录来源已经选定")
}
deleteUserID := couple.Int("user1_id")
if deleteUserID == keepUserID {
deleteUserID = couple.Int("user2_id")
}
for _, table := range []string{"period_records", "period_daily_records", "period_sex_records"} {
if _, err := tx.Exec("DELETE FROM "+table+" WHERE user_id = ? AND couple_id IS NULL", deleteUserID); err != nil {
return err
}
}
// daily 的旧 NULL 记录可能有重复日期,逐条迁入以避免情侣日期唯一索引冲突。
daily, err := tx.Query("SELECT id, record_date FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL ORDER BY id DESC FOR UPDATE", keepUserID)
if err != nil {
return err
}
for _, record := range daily {
existing, err := tx.QueryOne("SELECT id FROM period_daily_records WHERE couple_id = ? AND record_date = ? LIMIT 1", cid, record["record_date"])
if err != nil {
return err
}
if existing != nil {
if _, err := tx.Exec("DELETE FROM period_daily_records WHERE id = ?", record.Int("id")); err != nil {
return err
}
} else if _, err := tx.Exec("UPDATE period_daily_records SET couple_id = ? WHERE id = ?", cid, record.Int("id")); err != nil {
return err
}
}
for _, table := range []string{"period_records", "period_sex_records"} {
if _, err := tx.Exec("UPDATE "+table+" SET couple_id = ? WHERE user_id = ? AND couple_id IS NULL", cid, keepUserID); err != nil {
return err
}
}
if _, err := tx.Exec("UPDATE couples SET period_source_user_id = ? WHERE id = ?", keepUserID, cid); err != nil {
return err
}
return tx.Commit()
}
func periodSourceSelect(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
if err := requirePeriodAccess(c); err != nil {
return err
}
keepUserID := c.InInt("user_id")
if err := selectPeriodSource(c, keepUserID); err != nil {
return err
}
return c.OK(M{"success": true, "source_user_id": keepUserID})
}
@@ -0,0 +1,128 @@
package handler
import (
"database/sql/driver"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"past/internal/config"
"past/internal/httpapi"
"past/internal/store"
"past/internal/testdb"
)
func permissionCtx(t *testing.T, method, uri, body string, steps []testdb.Step) (*httpapi.Ctx, *httptest.ResponseRecorder, *testdb.Script) {
t.Helper()
db, s := testdb.Open(t, steps)
out := httptest.NewRecorder()
g, _ := gin.CreateTestContext(out)
g.Request = httptest.NewRequest(method, uri, strings.NewReader(body))
g.Request.Header.Set("Authorization", "Bearer test")
g.Request.Header.Set("Content-Type", "application/json")
app := &httpapi.App{DB: &store.DB{DB: db}, Cfg: &config.Config{}}
return &httpapi.Ctx{G: g, R: g.Request, W: out, DB: app.DB, App: app, Method: method}, out, s
}
func uidStep(uid int64) testdb.Step {
return testdb.Step{Match: "FROM user_tokens", Columns: []string{"user_id"}, Rows: [][]driver.Value{{uid}}}
}
func cidStep(cid int64) testdb.Step {
return testdb.Step{Match: "SELECT couple_id FROM users WHERE id = ?", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{cid}}}
}
func TestAgreeStartsCoolingWithoutUnbinding(t *testing.T) {
c, out, s := permissionCtx(t, "POST", "/api/unbindRespond", `{"agree":true}`, []testdb.Step{
uidStep(2), cidStep(10),
{Match: "FROM couples WHERE id=? FOR UPDATE", Columns: []string{"id", "status"}, Rows: [][]driver.Value{{int64(10), "active"}}},
{Match: "FROM users WHERE id=? AND couple_id=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(2)}}},
{Match: "status='pending' AND requester_id!=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(8)}}},
{Match: "cooling_until=DATE_ADD(NOW(),INTERVAL 72 HOUR)", Args: []any{int64(8)}},
})
unbindRespond(c)
if out.Code != 200 || s.Commits != 1 {
t.Fatalf("code=%d commits=%d body=%s", out.Code, s.Commits, out.Body.String())
}
}
func TestEitherPartnerCanCancelCoolingButNotAfterDeadline(t *testing.T) {
for _, expired := range []bool{false, true} {
t.Run(map[bool]string{false: "partner cancels", true: "deadline reached"}[expired], func(t *testing.T) {
rows := [][]driver.Value{{int64(8)}}
if expired {
rows = nil
}
steps := []testdb.Step{uidStep(2), cidStep(10), {Match: "FOR UPDATE", Columns: []string{"id", "status"}, Rows: [][]driver.Value{{int64(10), "active"}}}, {Match: "FROM users WHERE id=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(2)}}}, {Match: "status='cooling' AND cooling_until>NOW()", Columns: []string{"id"}, Rows: rows}}
if !expired {
steps = append(steps, testdb.Step{Match: "status='cancelled'"})
}
c, out, s := permissionCtx(t, "POST", "/api/unbindCancel", `{}`, steps)
unbindCancel(c)
want := 200
if expired {
want = 409
if s.Commits != 0 {
t.Fatal("expired cancellation committed")
}
}
if out.Code != want {
t.Fatalf("code=%d body=%s", out.Code, out.Body.String())
}
})
}
}
func TestLegacyDetailsStillRequireCurrentMemberAuthors(t *testing.T) {
for _, tt := range []struct {
name, predicate string
handler httpapi.HandlerFunc
}{
{"diary", "d.created_by IN", diaryDetail},
{"timeline", "WHERE id = ? AND created_by IN", timelineDetail},
{"checkin", "c.created_by IN", checkinDetail},
} {
t.Run(tt.name, func(t *testing.T) {
c, out, _ := permissionCtx(t, "GET", "/api/detail?id=9", "", []testdb.Step{uidStep(1), cidStep(22), {Match: "SELECT user1_id, user2_id FROM couples WHERE id = ?", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(3)}}}, {Match: tt.predicate, Columns: []string{"id"}}})
tt.handler(c)
if out.Code != 404 {
t.Fatalf("missing or unrelated detail returned %d: %s", out.Code, out.Body.String())
}
})
}
}
func TestLegacyMemberRecordsRemainReadableAcrossHistoricalSpaceIDs(t *testing.T) {
for _, tt := range []struct {
name, predicate string
handler httpapi.HandlerFunc
}{
{"timeline", "FROM timeline WHERE id = ? AND created_by IN", timelineDetail},
{"photos", "WHERE a.created_by IN (?, ?) AND p.album_id = ?", photos},
} {
t.Run(tt.name, func(t *testing.T) {
c, out, _ := permissionCtx(t, "GET", "/api/detail?id=9&album_id=9", "", []testdb.Step{
uidStep(1), cidStep(22),
{Match: "SELECT user1_id, user2_id FROM couples WHERE id = ?", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(3)}}},
{Match: tt.predicate, Columns: []string{"id", "couple_id", "created_by", "path"}, Rows: [][]driver.Value{{int64(9), int64(10), int64(1), "uploads/users/1/photos/a.jpg"}}},
})
tt.handler(c)
if out.Code != 200 || !strings.Contains(out.Body.String(), "uploads/users/1/photos/a.jpg") {
t.Fatalf("legacy record disappeared: code=%d body=%s", out.Code, out.Body.String())
}
})
}
}
func TestLegacyPeriodReadsSharedTablesInsteadOfPersonalTables(t *testing.T) {
c, out, _ := permissionCtx(t, "GET", "/api/periodList", "", []testdb.Step{
uidStep(1), cidStep(10),
{Match: "SELECT period_visible FROM users", Columns: []string{"period_visible"}, Rows: [][]driver.Value{{int64(1)}}},
{Match: "SELECT id, period_visible FROM users WHERE couple_id", Columns: []string{"id", "period_visible"}, Rows: [][]driver.Value{{int64(2), int64(1)}}},
{Match: "SELECT user1_id, user2_id, period_source_user_id", Columns: []string{"user1_id", "user2_id", "period_source_user_id"}, Rows: [][]driver.Value{{int64(1), int64(2), int64(2)}}},
{Match: "FROM period_records WHERE couple_id = ?", Args: []any{int64(10)}, Columns: []string{"id", "user_id"}, Rows: [][]driver.Value{{int64(7), int64(2)}}},
{Match: "FROM period_daily_records WHERE couple_id = ?", Args: []any{int64(10)}, Columns: []string{"id"}},
{Match: "FROM period_sex_records WHERE couple_id = ?", Args: []any{int64(10)}, Columns: []string{"id"}},
})
periodList(c)
if out.Code != 200 || !strings.Contains(out.Body.String(), `"user_id":"2"`) {
t.Fatalf("shared period missing: %d %s", out.Code, out.Body.String())
}
}
+522
View File
@@ -0,0 +1,522 @@
package handler
import (
"fmt"
"math"
"time"
"past/internal/httpapi"
"past/internal/store"
)
func registerPet(r *httpapi.Router) {
r.Handle("pet_info", petInfo)
r.Handle("pet_adopt", petAdopt)
r.Handle("pet_daily_action", petDailyAction)
r.Handle("pet_daily_status", petDailyStatus)
r.Handle("pet_achievements", petAchievements)
r.Handle("pet_rename", petRename)
r.Handle("pet_equip", petEquip)
r.Handle("pet_dynamics", petDynamics)
}
type petAch struct{ Key, Name, Desc, Icon string }
// allPetAchievements 对应 getAllPetAchievements()(保持顺序)
var allPetAchievements = []petAch{
{"first_adopt", "初次相遇", "领养第一只宠物", "🐣"},
{"level_5", "成长之路", "宠物达到5级", "⭐"},
{"level_10", "小有成就", "宠物达到10级", "🌟"},
{"level_20", "资深铲屎官", "宠物达到20级", "👑"},
{"level_30", "终极陪伴", "宠物达到30级", "💎"},
{"stage_2", "少年初长成", "宠物进入少年期", "🐕"},
{"stage_3", "青春活力", "宠物进入青年期", "🦮"},
{"stage_4", "成熟稳重", "宠物进入成年期", "🐩"},
{"stage_5", "终极形态", "宠物进入终极期", "🦁"},
{"all_done_7", "一周全勤", "连续7天完成所有任务", "📅"},
{"all_done_30", "月度之星", "连续30天完成所有任务", "🏆"},
{"feed_100", "大胃王", "累计喂食100次", "🍖"},
{"walk_50", "散步达人", "累计散步50次", "🚶"},
{"bath_30", "爱干净", "累计洗澡30次", "🛁"},
{"play_50", "玩伴", "累计玩耍50次", "🎾"},
{"happiness_100", "快乐满分", "宠物心情达到100", "😊"},
{"rename_first", "取名大师", "第一次给宠物改名", "✏️"},
{"adopt_7days", "一周陪伴", "领养满7天", "📆"},
{"adopt_30days", "一月相伴", "领养满30天", "🗓️"},
{"adopt_100days", "百日纪念", "领养满100天", "💯"},
}
func petAchByKey(key string) petAch {
for _, a := range allPetAchievements {
if a.Key == key {
return a
}
}
return petAch{}
}
// consecutiveDoneDays 从按日期倒序的 all_done 记录统计连续天数
func consecutiveDoneDays(rows []store.Row) int64 {
var consecutive int64
var prev time.Time
for i, r := range rows {
cur, _ := parseDate(r.Str("date"))
if i == 0 {
consecutive = 1
} else {
if math.Round(prev.Sub(cur).Hours()/24) == 1 {
consecutive++
} else {
break
}
}
prev = cur
}
return consecutive
}
// checkPetAchievements 对应 checkPetAchievements($db, $coupleId, $level, $stage, $happiness, $extra)
// happiness < 0 表示 null(不检查心情成就)
func checkPetAchievements(c *httpapi.Ctx, coupleID, level, stage, happiness int64, rename bool) {
db := c.DB
rs, _ := db.Query("SELECT achievement_key FROM pet_achievements WHERE couple_id = ?", coupleID)
unlocked := map[string]bool{}
for _, r := range rs {
unlocked[r.Str("achievement_key")] = true
}
var toUnlock []string
add := func(cond bool, key string) {
if cond && !unlocked[key] {
toUnlock = append(toUnlock, key)
}
}
add(true, "first_adopt")
add(level >= 5, "level_5")
add(level >= 10, "level_10")
add(level >= 20, "level_20")
add(level >= 30, "level_30")
add(stage >= 2, "stage_2")
add(stage >= 3, "stage_3")
add(stage >= 4, "stage_4")
add(stage >= 5, "stage_5")
if p, _ := db.QueryOne("SELECT adopted_at FROM pets WHERE couple_id = ?", coupleID); p != nil && p.Str("adopted_at") != "" {
if t, err := parseDate(p.Str("adopted_at")); err == nil {
days := int64(math.Floor(time.Since(t).Seconds() / 86400))
add(days >= 7, "adopt_7days")
add(days >= 30, "adopt_30days")
add(days >= 100, "adopt_100days")
}
}
add(happiness >= 0 && happiness >= 100, "happiness_100")
if !unlocked["feed_100"] || !unlocked["walk_50"] || !unlocked["bath_30"] || !unlocked["play_50"] {
if cnt, _ := db.QueryOne("SELECT SUM(feed_u1 + feed_u2) as feed_cnt, SUM(walk_u1 + walk_u2) as walk_cnt, SUM(bath_u1 + bath_u2) as bath_cnt, SUM(play_u1 + play_u2) as play_cnt FROM pet_dailies WHERE couple_id = ?", coupleID); cnt != nil {
add(cnt.Int("feed_cnt") >= 100, "feed_100")
add(cnt.Int("walk_cnt") >= 50, "walk_50")
add(cnt.Int("bath_cnt") >= 30, "bath_30")
add(cnt.Int("play_cnt") >= 50, "play_50")
}
}
if !unlocked["all_done_7"] || !unlocked["all_done_30"] {
done, _ := db.Query("SELECT date, all_done FROM pet_dailies WHERE couple_id = ? AND all_done = 1 ORDER BY date DESC", coupleID)
consecutive := consecutiveDoneDays(done)
add(consecutive >= 7, "all_done_7")
add(consecutive >= 30, "all_done_30")
}
add(rename, "rename_first")
if len(toUnlock) == 0 {
return
}
// pet_dynamics.user_id 有外键指向 users,而系统动态写 user_id=0:PHP 版本在此处因外键失败而 500,
// Go 版本在独占连接上临时关闭外键检查写入(语义即"系统"动态)。
db.ExecNoFK(func(q store.Querier) error {
for _, key := range toUnlock {
q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, ?)", coupleID, key)
a := petAchByKey(key)
q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:"+a.Name+" "+a.Icon)
}
return nil
})
}
// coupleUserIDsOrdered 对应 SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2 → [user1, user2](缺省 0)
func coupleUserIDsOrdered(c *httpapi.Ctx, coupleID int64) (int64, int64) {
r1, r2 := coupleUserIDsRaw(c, coupleID)
return store.ToInt(r1), store.ToInt(r2)
}
// coupleUserIDsRaw 同上,但保留 PHP 的原始值类型($users[0] ?? 0:存在为行值,缺省为整数 0)
func coupleUserIDsRaw(c *httpapi.Ctx, coupleID int64) (any, any) {
rs, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2", coupleID)
var u1, u2 any = int64(0), int64(0)
if len(rs) > 0 {
u1 = rs[0]["id"]
}
if len(rs) > 1 {
u2 = rs[1]["id"]
}
return u1, u2
}
func petInfo(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", coupleID)
if err != nil {
return err
}
if pet == nil {
return c.OK(M{"has_pet": false, "pet": nil})
}
if fa, _ := c.DB.QueryOne("SELECT id FROM pet_achievements WHERE couple_id = ? AND achievement_key = 'first_adopt'", coupleID); fa == nil {
c.DB.ExecNoFK(func(q store.Querier) error {
q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, 'first_adopt')", coupleID)
q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:初次相遇 🐣")
return nil
})
}
daily, _ := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, today())
achs, _ := c.DB.Query("SELECT achievement_key, unlocked_at FROM pet_achievements WHERE couple_id = ?", coupleID)
achKeys := []any{}
for _, a := range achs {
achKeys = append(achKeys, a["achievement_key"])
}
equipped, _ := c.DB.Query("SELECT item_key, item_type FROM pet_items WHERE couple_id = ? AND equipped = 1", coupleID)
dynamics, _ := c.DB.Query("SELECT pd.*, u.nickname FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT 20", coupleID)
u1raw, u2raw := coupleUserIDsRaw(c, coupleID)
u1, u2 := store.ToInt(u1raw), store.ToInt(u2raw)
expForNext := pet.Int("level") * 100
var expProgress any = 0
if expForNext > 0 {
p := math.Round(float64(pet.Int("exp")) / float64(expForNext) * 100)
if p > 100 {
p = 100
}
expProgress = p
}
counts := M{"feed": 0, "walk": 0, "bath": 0, "play": 0}
if ac, _ := c.DB.QueryOne("SELECT SUM(feed_u1 + feed_u2) as feed_cnt, SUM(walk_u1 + walk_u2) as walk_cnt, SUM(bath_u1 + bath_u2) as bath_cnt, SUM(play_u1 + play_u2) as play_cnt FROM pet_dailies WHERE couple_id = ?", coupleID); ac != nil {
counts = M{"feed": ac.Int("feed_cnt"), "walk": ac.Int("walk_cnt"), "bath": ac.Int("bath_cnt"), "play": ac.Int("play_cnt")}
}
done, _ := c.DB.Query("SELECT date, all_done FROM pet_dailies WHERE couple_id = ? AND all_done = 1 ORDER BY date DESC", coupleID)
var dailyOut any
if daily != nil {
dailyOut = daily
}
return c.OK(M{
"has_pet": true, "pet": pet, "daily": dailyOut, "achievements": achKeys, "equipped": rows(equipped), "dynamics": rows(dynamics),
"user1_id": u1raw, "user2_id": u2raw, "is_user1": uid == u1, "is_user2": uid == u2,
"exp_for_next_level": expForNext, "exp_progress": expProgress, "action_counts": counts,
"consecutive_done_days": consecutiveDoneDays(done),
})
}
func petAdopt(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
name := c.InTrim("name")
if !c.InHas("name") {
name = "小狗狗"
}
breed := c.InTrim("breed")
if !c.InHas("breed") {
breed = "golden"
}
gender := int64(1)
if c.InHas("gender") {
gender = c.InInt("gender")
}
switch breed {
case "golden", "husky", "corgi", "shiba":
default:
breed = "golden"
}
if ex, _ := c.DB.QueryOne("SELECT id FROM pets WHERE couple_id = ?", coupleID); ex != nil {
return c.Err(400, "已领养过宠物")
}
petID, err := c.DB.Insert("INSERT INTO pets (couple_id, name, breed, gender, stage, level, exp, happiness, hunger, energy, cleanliness, adopted_at, created_by) VALUES (?, ?, ?, ?, 1, 1, 0, 100, 100, 100, 100, ?, ?)",
coupleID, name, breed, gender, today(), uid)
if err != nil {
return err
}
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'adopt', ?)", coupleID, uid, "领养了"+name+"!🎉")
actID := logActivity(c.DB, coupleID, uid, "pet_adopt", "领养了宠物"+name, "")
c.DB.Exec("INSERT INTO pet_items (couple_id, item_key, item_type, quantity, equipped) VALUES (?, 'collar_basic', 'collar', 1, 1)", coupleID)
checkPetAchievements(c, coupleID, 1, 1, 100, false)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
// 使用活动ID记录发送事件,供发送日志和去重查询使用。
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "领养了新宠物 🐾", "thing2": "TA领养了「" + name + "」,快来一起照顾吧", "date3": today(), "thing4": "点击查看宠物详情 💕",
}, "pages/pet/pet", actID, uid)
}
return c.OK(M{"success": true, "pet_id": fmt.Sprint(petID)})
}
var petActionEffects = map[string]map[string]int64{
"feed": {"exp": 10, "happiness": 3, "hunger": 15},
"water": {"exp": 8, "happiness": 2, "energy": 5},
"walk": {"exp": 15, "happiness": 8, "energy": -10, "cleanliness": -5},
"bath": {"exp": 12, "happiness": 5, "cleanliness": 20, "energy": -5},
"sleep": {"exp": 5, "energy": 20, "happiness": 3},
"play": {"exp": 12, "happiness": 10, "energy": -8},
}
var petActionMessages = map[string]string{
"feed": "喂食了宠物 🍖", "water": "给宠物喝了水 💧", "walk": "带宠物去散步 🐾",
"bath": "给宠物洗了澡 🛁", "sleep": "哄宠物睡觉了 😴", "play": "和宠物玩耍了 🎾",
}
var petDailyFields = []string{"feed_u1", "feed_u2", "water_u1", "water_u2", "walk_u1", "walk_u2", "bath_u1", "bath_u2", "sleep_u1", "sleep_u2", "play_u1", "play_u2"}
func clamp100(v int64) int64 {
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}
func petDailyAction(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
action := c.InTrim("action")
effects, ok := petActionEffects[action]
if !ok {
return c.Err(400, "无效的动作")
}
pet, _ := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", coupleID)
if pet == nil {
return c.Err(400, "请先领养宠物")
}
u1, _ := coupleUserIDsOrdered(c, coupleID)
suffix := "u2"
if uid == u1 {
suffix = "u1"
}
td := today()
daily, _ := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, td)
if daily == nil {
if _, err := c.DB.Exec("INSERT INTO pet_dailies (couple_id, date) VALUES (?, ?)", coupleID, td); err != nil {
return err
}
daily, _ = c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, td)
}
field := action + "_" + suffix
if daily.Int(field) == 1 {
return c.JSON(400, M{"error": "你今天已完成此任务", "already_done": true})
}
if _, err := c.DB.Exec("UPDATE pet_dailies SET "+field+" = 1 WHERE id = ?", daily.Int("id")); err != nil {
return err
}
allDone := true
for _, f := range petDailyFields {
if daily.Int(f) == 0 && f != field {
row, _ := c.DB.QueryOne("SELECT "+f+" FROM pet_dailies WHERE id = ?", daily.Int("id"))
if row == nil || row.Int(f) == 0 {
allDone = false
break
}
}
}
newHappiness := clamp100(pet.Int("happiness") + effects["happiness"])
newHunger := clamp100(pet.Int("hunger") + effects["hunger"])
newEnergy := clamp100(pet.Int("energy") + effects["energy"])
newClean := clamp100(pet.Int("cleanliness") + effects["cleanliness"])
newExp := pet.Int("exp") + effects["exp"]
newLevel := pet.Int("level")
// PHP: $newLevel = $pet['level'] 为行值(8.0 下是字符串),只有 ++ 后才变为整数;$newStage 同理
var levelOut, stageOut any = pet["level"], pet["stage"]
expNeeded := newLevel * 100
for newExp >= expNeeded {
newExp -= expNeeded
newLevel++
levelOut = newLevel
expNeeded = newLevel * 100
}
newStage := pet.Int("stage")
switch {
case newLevel >= 30:
newStage = 5
case newLevel >= 20:
newStage = 4
case newLevel >= 12:
newStage = 3
case newLevel >= 5:
newStage = 2
}
if newStage != pet.Int("stage") || newLevel >= 5 {
stageOut = newStage
}
if _, err := c.DB.Exec("UPDATE pets SET happiness = ?, hunger = ?, energy = ?, cleanliness = ?, exp = ?, level = ?, stage = ?, updated_at = NOW() WHERE id = ?",
newHappiness, newHunger, newEnergy, newClean, newExp, newLevel, newStage, pet.Int("id")); err != nil {
return err
}
msg := petActionMessages[action]
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, ?, ?)", coupleID, uid, action, msg)
if allDone {
c.DB.Exec("UPDATE pet_dailies SET all_done = 1 WHERE id = ?", daily.Int("id"))
c.DB.Exec("UPDATE pets SET exp = exp + 20, happiness = happiness + 10, updated_at = NOW() WHERE id = ?", pet.Int("id"))
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'all_done', '今日所有任务完成!太棒了 🎉')", coupleID, uid)
logActivity(c.DB, coupleID, uid, "pet_all_done", "今日宠物所有任务完成!", "")
}
checkPetAchievements(c, coupleID, newLevel, newStage, newHappiness, false)
actID := logActivity(c.DB, coupleID, uid, "pet_daily", msg, "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
notifyMsg := msg
if allDone {
notifyMsg = msg + ",今日所有任务完成!🎉"
}
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "宠物日常 🐾", "thing2": notifyMsg, "date3": td, "thing4": "点击查看宠物详情 💕",
}, "pages/pet/pet", actID, uid)
}
return c.OK(M{
"success": true, "exp_gain": effects["exp"], "happiness": newHappiness, "hunger": newHunger, "energy": newEnergy,
"cleanliness": newClean, "level": levelOut, "stage": stageOut, "all_done": allDone,
})
}
func petDailyStatus(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
date := c.Q("date")
if !c.QHas("date") {
date = today()
}
daily, err := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, date)
if err != nil {
return err
}
u1, _ := coupleUserIDsOrdered(c, coupleID)
suffix, partnerSuffix := "u2", "u1"
if uid == u1 {
suffix, partnerSuffix = "u1", "u2"
}
var myDone, partnerDone any = []any{}, []any{}
var allDone any = false
if daily != nil {
md, pd := M{}, M{}
for _, a := range []string{"feed", "water", "walk", "bath", "sleep", "play"} {
md[a] = daily.Int(a+"_"+suffix) == 1
pd[a] = daily.Int(a+"_"+partnerSuffix) == 1
}
myDone, partnerDone = md, pd
allDone = daily["all_done"]
}
return c.OK(M{"date": date, "has_record": daily != nil, "my_done": myDone, "partner_done": partnerDone, "all_done": allDone})
}
func petAchievements(c *httpapi.Ctx) error {
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
unlocked, err := c.DB.Query("SELECT achievement_key, unlocked_at FROM pet_achievements WHERE couple_id = ? ORDER BY unlocked_at ASC", coupleID)
if err != nil {
return err
}
at := map[string]any{}
for _, u := range unlocked {
k := u.Str("achievement_key")
if _, seen := at[k]; !seen {
at[k] = u["unlocked_at"]
}
}
result := make([]M, 0, len(allPetAchievements))
for _, a := range allPetAchievements {
ua, ok := at[a.Key]
if !ok {
ua = nil
}
result = append(result, M{"key": a.Key, "name": a.Name, "desc": a.Desc, "icon": a.Icon, "unlocked": ok, "unlocked_at": ua})
}
return c.OK(M{"achievements": result})
}
func petRename(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
name := c.InTrim("name")
if !store.Truthy(name) || len([]rune(name)) > 20 {
return c.Err(400, "名称长度1-20个字符")
}
if _, err := c.DB.Exec("UPDATE pets SET name = ?, updated_at = NOW() WHERE couple_id = ?", name, coupleID); err != nil {
return err
}
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'rename', ?)", coupleID, uid, "给宠物改名为"+name+" ✏️")
actID := logActivity(c.DB, coupleID, uid, "pet_rename", "给宠物改名为"+name, "")
info, _ := c.DB.QueryOne("SELECT level, stage, happiness FROM pets WHERE couple_id = ?", coupleID)
var level, stage, happiness int64
if info != nil {
level, stage, happiness = info.Int("level"), info.Int("stage"), info.Int("happiness")
}
checkPetAchievements(c, coupleID, level, stage, happiness, true)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "宠物改名 ✏️", "thing2": "TA给宠物改名为「" + name + "」", "date3": today(), "thing4": "点击查看新名字 💕",
}, "pages/pet/pet", actID, uid)
}
return c.OK(M{"success": true, "name": name})
}
func petEquip(c *httpapi.Ctx) error {
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
itemKey := c.InTrim("item_key")
equip := int64(1)
if c.InHas("equip") {
equip = c.InInt("equip")
}
if !store.Truthy(itemKey) {
return c.Err(400, "缺少物品标识")
}
item, _ := c.DB.QueryOne("SELECT * FROM pet_items WHERE couple_id = ? AND item_key = ?", coupleID, itemKey)
if item == nil {
return c.Err(400, "物品不存在")
}
if equip != 0 {
c.DB.Exec("UPDATE pet_items SET equipped = 0 WHERE couple_id = ? AND item_type = ? AND item_key != ?", coupleID, item["item_type"], itemKey)
c.DB.Exec("UPDATE pet_items SET equipped = 1 WHERE couple_id = ? AND item_key = ?", coupleID, itemKey)
} else {
c.DB.Exec("UPDATE pet_items SET equipped = 0 WHERE couple_id = ? AND item_key = ?", coupleID, itemKey)
}
return c.OK(M{"success": true, "equipped": equip == 1})
}
func petDynamics(c *httpapi.Ctx) error {
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
page := int64(1)
if c.QHas("page") {
page = c.QInt("page")
}
limit := int64(20)
offset := (page - 1) * limit
dynamics, err := c.DB.Query("SELECT pd.*, u.nickname, u.avatar FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT ? OFFSET ?", coupleID, limit, offset)
if err != nil {
return err
}
totalRaw := countRaw(c, "total", "SELECT COUNT(*) as total FROM pet_dynamics WHERE couple_id = ?", coupleID)
total := store.ToInt(totalRaw)
return c.OK(M{"dynamics": rows(dynamics), "total": totalRaw, "page": page, "has_more": (offset + limit) < total})
}
+269
View File
@@ -0,0 +1,269 @@
package handler
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"past/internal/httpapi"
"past/internal/store"
)
func guardSmokingCheckin(c *httpapi.Ctx, id int64) error {
row, err := c.DB.QueryOne("SELECT id FROM smoking_accounts WHERE checkin_id = ?", id)
if err != nil {
return err
}
if row != nil {
return c.Err(400, "请通过抽烟记录操作库存和撤销")
}
return nil
}
func smokingDelta(kind string, quantity, stock int64) (int64, error) {
if quantity < 0 || quantity > 1000000 {
return 0, errors.New("数量超出范围")
}
switch kind {
case "consume", "give":
if quantity != 1 {
return 0, errors.New("每次操作一支")
}
if stock < 1 {
return 0, errors.New("库存不足,请先补充库存")
}
return -1, nil
case "receive_smoke":
if quantity != 1 {
return 0, errors.New("每次记录一支")
}
return 0, nil
case "purchase", "receive":
if quantity < 1 || quantity > 10000 {
return 0, errors.New("补充数量应为1到10000支")
}
return quantity, nil
case "adjust":
return quantity - stock, nil
default:
return 0, errors.New("不支持的操作")
}
}
func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "week_count": int64(0), "month_count": int64(0), "week_purchase_cents": int64(0), "month_purchase_cents": int64(0), "logs": []any{}, "logs_total": int64(0), "logs_page": int64(1), "logs_has_more": false, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "available": true}
owner, err := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", uid)
if err != nil {
return nil, err
}
if owner != nil {
result["owner_name"] = owner.Str("nickname")
if owner.Str("nickname") == "" {
result["owner_name"] = owner.Str("username")
}
}
partner := partnerUserId(c.DB, c.UID())
result["partner_id"] = partner
account, err := c.DB.QueryOne("SELECT user_id AS id, stock FROM smoking_wallets WHERE user_id = ?", uid)
if err != nil {
return nil, err
}
if account == nil {
return result, nil
}
aid := account.Int("id")
result["stock"] = account.Int("stock")
summary, err := c.DB.QueryOne(`SELECT
COALESCE(SUM(kind IN ('consume','receive_smoke')),0) AS total_count,
COALESCE(SUM(kind IN ('consume','receive_smoke') AND created_at >= CURRENT_DATE),0) AS today_count,
COALESCE(SUM(kind IN ('consume','receive_smoke') AND created_at >= ?),0) AS week_count,
COALESCE(SUM(kind IN ('consume','receive_smoke') AND created_at >= ?),0) AS month_count,
COALESCE(SUM(CASE WHEN kind = 'purchase' AND created_at >= ? THEN amount_cents ELSE 0 END),0) AS week_purchase_cents,
COALESCE(SUM(CASE WHEN kind = 'purchase' AND created_at >= ? THEN amount_cents ELSE 0 END),0) AS month_purchase_cents,
MAX(CASE WHEN kind IN ('consume','receive_smoke') THEN created_at ELSE NULL END) AS last_time
FROM smoking_entries WHERE user_id = ? AND undone = 0`, weekMonday(), monthStart(), weekMonday(), monthStart(), aid)
if err != nil {
return nil, err
}
if summary != nil {
result["today_count"] = summary.Int("today_count")
result["total_count"] = summary.Int("total_count")
for _, field := range []string{"week_count", "month_count", "week_purchase_cents", "month_purchase_cents"} {
result[field] = summary.Int(field)
}
result["last_time"] = summary.Str("last_time")
}
page, size, offset := pageParams(c, 20, 100)
result["logs_page"] = page
result["logs_total"] = countC(c, "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id=?", aid)
logs, err := c.DB.Query("SELECT id, kind, quantity, delta, balance, amount_cents, note, undone, created_at FROM smoking_entries WHERE user_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", aid, size, offset)
if err != nil {
return nil, err
}
result["logs"] = rows(logs)
result["logs_has_more"] = offset+int64(len(logs)) < store.ToInt(result["logs_total"])
daily, err := c.DB.Query("SELECT DATE_FORMAT(created_at,'%Y-%m-%d') AS day, COUNT(*) AS count FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0 AND created_at >= DATE_SUB(CURRENT_DATE, INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at,'%Y-%m-%d') ORDER BY day", aid)
if err != nil {
return nil, err
}
result["daily"] = rows(daily)
return result, nil
}
func smokingInfo(c *httpapi.Ctx) error {
uid := c.UID()
requested := c.QInt("user_id")
if requested == 0 {
requested = uid
}
if requested != uid && requested != partnerUserId(c.DB, uid) {
return c.Err(403, "只能查看自己和当前伴侣的记录")
}
info, err := smokingSnapshot(c, requested)
if err != nil {
return err
}
return c.OK(info)
}
func smokingAction(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
var err error
uid := c.UID()
kind, key, note := c.InTrim("kind"), c.InTrim("request_key"), c.InTrim("note")
quantity, quantityErr := strconv.ParseInt(c.InTrim("quantity"), 10, 64)
optionalInt := func(key string) (int64, error) {
value := c.InTrim(key)
if value == "" {
return 0, nil
}
return strconv.ParseInt(value, 10, 64)
}
amount, amountErr := optionalInt("amount_cents")
originalID, originalErr := optionalInt("original_id")
if quantityErr != nil || amountErr != nil || originalErr != nil || (kind != "purchase" && amount != 0) || (kind != "undo" && originalID != 0) || (kind == "undo" && (originalID <= 0 || quantity != 0)) {
return c.Err(400, "数量、金额和记录标识必须是有效整数")
}
if key == "" || len(key) > 64 || utf8.RuneCountInString(note) > 200 || amount < 0 || amount > 100000000 {
return c.Err(400, "参数不正确")
}
if kind != "undo" {
if _, err := smokingDelta(kind, quantity, 1000000); err != nil {
return c.Err(400, err.Error())
}
}
if note != "" {
if err := checkTextOr400(c, note); err != nil {
return err
}
}
payload, _ := json.Marshal([]any{kind, quantity, amount, originalID, note})
hash := fmt.Sprintf("%x", sha256.Sum256(payload))
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec("INSERT IGNORE INTO smoking_wallets (user_id) VALUES (?)", uid); err != nil {
return err
}
account, err := tx.QueryOne("SELECT user_id AS id,stock FROM smoking_wallets WHERE user_id = ? FOR UPDATE", uid)
if err != nil {
return err
}
if account == nil {
return errors.New("库存账户创建失败")
}
aid, stock := account.Int("id"), account.Int("stock")
previous, err := tx.QueryOne("SELECT payload_hash FROM smoking_entries WHERE user_id = ? AND request_key = ?", aid, key)
if err != nil {
return err
}
if previous != nil {
if previous.Str("payload_hash") != hash {
return c.Err(409, "请求标识已被其他操作使用")
}
if err = tx.Commit(); err != nil {
return err
}
info, err := smokingSnapshot(c, uid)
if err != nil {
return err
}
return c.OK(M{"success": true, "info": info})
}
var delta int64
if kind == "undo" {
original, err := tx.QueryOne("SELECT id,delta,kind,undone FROM smoking_entries WHERE user_id = ? AND id = ? FOR UPDATE", aid, originalID)
if err != nil {
return err
}
if original == nil || original.Int("undone") != 0 || original.Str("kind") == "undo" {
return c.Err(409, "这条记录无法撤销或已经撤销")
}
delta = -original.Int("delta")
quantity = 0
if stock+delta < 0 {
return c.Err(409, "库存已使用,无法撤销这次补充,请通过调整库存修正")
}
if _, err = tx.Exec("UPDATE smoking_entries SET undone = 1 WHERE id = ?", originalID); err != nil {
return err
}
} else {
delta, err = smokingDelta(kind, quantity, stock)
if err != nil {
return c.Err(400, err.Error())
}
}
if stock+delta > 1000000 && delta > 0 {
return c.Err(400, "库存不能超过100万支")
}
if _, err = tx.Exec("UPDATE smoking_wallets SET stock = ? WHERE user_id = ?", stock+delta, aid); err != nil {
return err
}
if _, err = tx.Insert("INSERT INTO smoking_entries (user_id,kind,quantity,delta,balance,amount_cents,note,request_key,payload_hash,original_id) VALUES (?,?,?,?,?,?,?,?,?,?)", aid, kind, quantity, delta, stock+delta, amount, strings.TrimSpace(note), key, hash, nullableID(originalID)); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
info, err := smokingSnapshot(c, uid)
if err != nil {
return err
}
// 仅记录抽烟/消耗类动作时通知伴侣(撤销、补库存不打扰)
var smokeDesc string
switch kind {
case "consume":
smokeDesc = "TA 记录抽了一支烟"
case "give":
smokeDesc = "TA 递出了一支烟"
case "receive_smoke":
smokeDesc = "TA 记录收到一支烟"
}
if smokeDesc != "" {
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
actID := logActivity(c.DB, c.CoupleID(), uid, "smoking_consume", smokeDesc, "")
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "戒烟打卡",
"thing2": fmt.Sprintf("%s,今日已 %d 支", smokeDesc, store.ToInt(info["today_count"])),
"date3": today(),
"thing4": "点击查看详情",
}, "pages/punch/punch", actID, uid)
}
}
return c.OK(M{"success": true, "info": info})
}
func nullableID(id int64) any {
if id == 0 {
return nil
}
return id
}
@@ -0,0 +1,62 @@
package handler
import (
"database/sql/driver"
"fmt"
"past/internal/testdb"
"strings"
"testing"
)
func TestSmokingSnapshotWeeklyMonthlyStats(t *testing.T) {
for _, wallet := range []bool{false, true} {
t.Run(fmt.Sprint(wallet), func(t *testing.T) {
account := [][]driver.Value(nil)
if wallet {
account = [][]driver.Value{{int64(2), int64(18)}}
}
steps := []testdb.Step{
uidStep(1),
{Match: "SELECT nickname, username", Columns: []string{"nickname", "username"}, Rows: [][]driver.Value{{"TA", "partner"}}},
{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(10)}}},
{Match: "SELECT user1_id, user2_id FROM couples", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(2)}}},
{Match: "FROM smoking_wallets", Args: []any{int64(2)}, Columns: []string{"id", "stock"}, Rows: account},
}
if wallet {
steps = append(steps,
testdb.Step{Match: "AS week_purchase_cents", Args: []any{weekMonday(), monthStart(), weekMonday(), monthStart(), int64(2)}, Check: func(query string, args []any) error {
for _, expected := range []string{"kind IN ('consume','receive_smoke')", "kind = 'purchase'", "AND undone = 0", "AS month_purchase_cents"} {
if !strings.Contains(query, expected) {
return fmt.Errorf("missing %s", expected)
}
}
if strings.Contains(query, "LIMIT") {
return fmt.Errorf("statistics must cover all entries")
}
return nil
}, Columns: []string{"today_count", "total_count", "week_count", "month_count", "week_purchase_cents", "month_purchase_cents", "last_time"}, Rows: [][]driver.Value{{int64(1), int64(120), int64(7), int64(29), int64(3050), int64(12500), "2026-10-11 09:00:00"}}},
testdb.Step{Match: "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id=?", Args: []any{int64(2)}, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(120)}}},
testdb.Step{Match: "ORDER BY id DESC LIMIT ? OFFSET ?", Args: []any{int64(2), int64(20), int64(0)}, Columns: []string{"id"}},
testdb.Step{Match: "GROUP BY DATE_FORMAT", Columns: []string{"day", "count"}},
)
}
c, _, _ := permissionCtx(t, "GET", "/api/smokingInfo?user_id=2", "", steps)
result, err := smokingSnapshot(c, 2)
if err != nil {
t.Fatal(err)
}
if result["read_only"] != true {
t.Fatal("partner view must be read only")
}
expected := map[string]int64{"week_count": 0, "month_count": 0, "week_purchase_cents": 0, "month_purchase_cents": 0}
if wallet {
expected = map[string]int64{"week_count": 7, "month_count": 29, "week_purchase_cents": 3050, "month_purchase_cents": 12500}
}
for k, v := range expected {
if result[k] != v {
t.Fatalf("%s=%v want %d", k, result[k], v)
}
}
})
}
}
+38
View File
@@ -0,0 +1,38 @@
package handler
import "testing"
func TestSmokingDelta(t *testing.T) {
for _, tt := range []struct {
name, kind string
quantity, stock, want int64
valid bool
}{
{"give one", "give", 1, 20, -1, true},
{"cannot give from empty stock", "give", 1, 0, 0, false},
{"give exactly one", "give", 2, 20, 0, false},
{"consume one", "consume", 1, 20, -1, true},
{"empty stock", "consume", 1, 0, 0, false},
{"no bulk smoking", "consume", 2, 20, 0, false},
{"purchase pack", "purchase", 20, 0, 20, true},
{"received one", "receive", 1, 3, 1, true},
{"received and immediately consumed", "receive_smoke", 1, 0, 0, true},
{"initial stock", "adjust", 17, 0, 17, true},
{"inventory correction", "adjust", 3, 17, -14, true},
{"clear inventory", "adjust", 0, 17, -17, true},
{"negative quantity", "receive", -1, 0, 0, false},
{"zero purchase", "purchase", 0, 0, 0, false},
{"too much purchase", "purchase", 10001, 0, 0, false},
{"unknown operation", "other", 1, 0, 0, false},
} {
t.Run(tt.name, func(t *testing.T) {
delta, err := smokingDelta(tt.kind, tt.quantity, tt.stock)
if (err == nil) != tt.valid || delta != tt.want {
t.Fatalf("delta=%d err=%v", delta, err)
}
if err == nil && tt.stock+delta < 0 {
t.Fatal("negative inventory")
}
})
}
}
+100
View File
@@ -0,0 +1,100 @@
package handler
import (
"fmt"
"strings"
"past/internal/httpapi"
)
func registerSongs(r *httpapi.Router) {
r.Handle("songs_list", songsList)
r.Handle("songs_add", songsAdd)
r.Handle("songs_update", songsUpdate)
r.Handle("songs_delete", songsDelete)
}
func songsList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return demoListResponse(c, "songs", nil)
}
suffix, window := listWindow(c)
items, err := c.DB.Query("SELECT s.*, u.nickname, u.avatar FROM songs s LEFT JOIN users u ON s.created_by = u.id WHERE s.created_by IN (?, ?) ORDER BY s.created_at DESC, s.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...)
if err != nil {
return err
}
return listResponse(c, items, countC(c, "SELECT COUNT(*) as c FROM songs WHERE created_by IN (?, ?)", ids[0], ids[1]), nil)
}
func songText(c *httpapi.Ctx) string {
return strings.TrimSpace(c.InStr("song_name") + " " + c.InStr("artist") + " " + c.InStr("note"))
}
func songsAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
if !c.InBool("song_name") || !c.InBool("artist") {
return c.Err(400, "请填写歌曲名和歌手名")
}
uid := c.UID()
if err := checkTextOr400(c, songText(c)); err != nil {
return err
}
id, err := c.DB.Insert("INSERT INTO songs (couple_id, song_name, artist, note, created_by) VALUES (?, ?, ?, ?, ?)",
cid, c.In("song_name"), c.In("artist"), inOr(c, "note", ""), uid)
if err != nil {
return err
}
name, artist := c.InStr("song_name"), c.InStr("artist")
actID := logActivity(c.DB, cid, uid, "song_add", "收藏了「"+name+" - "+artist+"」", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "收藏了新歌曲 🎵", "thing2": mbTruncate(name, 20) + " - " + artist, "date3": today(), "thing4": "点击听听看 💕",
}, "pages/lovewords/lovewords", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
func songsUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if !c.InBool("id") {
return c.Err(400, "参数错误")
}
if !c.InBool("song_name") || !c.InBool("artist") {
return c.Err(400, "请填写歌曲名和歌手名")
}
if err := checkTextOr400(c, songText(c)); err != nil {
return err
}
if _, err := c.DB.Exec("UPDATE songs SET song_name = ?, artist = ?, note = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("song_name"), c.In("artist"), inOr(c, "note", ""), c.In("id"), ids[0], ids[1]); err != nil {
return err
}
return c.OK(M{"success": true})
}
func songsDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if _, err := c.DB.Exec("DELETE FROM songs WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil {
return err
}
return c.OK(M{"success": true})
}
+151
View File
@@ -0,0 +1,151 @@
package handler
import (
"fmt"
"strings"
"past/internal/httpapi"
)
func registerTimeline(r *httpapi.Router) {
r.Handle("timeline_list", timelineList)
r.Handle("timeline_add", timelineAdd)
r.Handle("timeline_update", timelineUpdate)
r.Handle("timeline_delete", timelineDelete)
r.Handle("timeline_detail", timelineDetail)
}
// requireCoupleUserIds 对应 $userIds = getCoupleUserIds(); if (empty($userIds)) jsonResponse(['error' => '请先绑定情侣'], 400);
func requireCoupleUserIds(c *httpapi.Ctx) ([]int64, error) {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return nil, c.Err(400, "请先绑定情侣")
}
return ids, nil
}
// requireCoupleID 对应 $cid = getCoupleId(); if (!$cid) jsonResponse(['error' => '请先绑定情侣'], 400);
func requireCoupleID(c *httpapi.Ctx) (int64, error) {
cid := c.CoupleID()
if cid == 0 {
return 0, c.Err(400, "请先绑定情侣")
}
return cid, nil
}
// checkTextOr400 文本安全检测;违规时已写出 400
func checkTextOr400(c *httpapi.Ctx, text string) error {
if pass, msg := c.App.Wechat.CheckText(text, userOpenId(c.DB, c.UID())); !pass {
return c.Err(400, msg)
}
return nil
}
func timelineList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return demoListResponse(c, "timeline", nil)
}
suffix, window := listWindow(c)
items, err := c.DB.Query("SELECT * FROM timeline WHERE created_by IN (?, ?) ORDER BY event_date DESC, sort_order ASC, id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...)
if err != nil {
return err
}
for _, it := range items {
it["images"] = jsonDecodeList(it["images"])
}
total := countC(c, "SELECT COUNT(*) as c FROM timeline WHERE created_by IN (?, ?)", ids[0], ids[1])
upcoming, err := c.DB.QueryOne("SELECT * FROM timeline WHERE created_by IN (?, ?) AND event_date >= CURRENT_DATE ORDER BY event_date ASC, id DESC LIMIT 1", ids[0], ids[1])
if err != nil {
return err
}
return listResponse(c, items, total, M{"upcoming": upcoming})
}
func timelineAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
return err
}
id, err := c.DB.Insert("INSERT INTO timeline (couple_id, title, content, event_date, location, repeat_interval, repeat_unit, bg_image, images, icon, sort_order, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""),
jsonEncode(inOr(c, "images", []any{})), inOr(c, "icon", "❤️"), inOr(c, "sort_order", 0), uid)
if err != nil {
return err
}
title := c.InStr("title")
actID := logActivity(c.DB, cid, uid, "timeline_create", "添加了记忆日「"+title+"」", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "记忆日更新",
"thing2": "TA记录了「" + mbTruncate(title, 20) + "」",
"date3": c.InStr("event_date"),
"thing4": "点击查看详情 💕",
}, "pages/timeline/timeline", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
func timelineUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
return err
}
_, err = c.DB.Exec("UPDATE timeline SET title = ?, content = ?, event_date = ?, location = ?, repeat_interval = ?, repeat_unit = ?, bg_image = ?, images = ?, icon = ?, sort_order = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""),
jsonEncode(inOr(c, "images", []any{})), inOr(c, "icon", "❤️"), inOr(c, "sort_order", 0), c.In("id"), ids[0], ids[1])
if err != nil {
return err
}
return c.OK(M{"success": true})
}
func timelineDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if _, err := c.DB.Exec("DELETE FROM timeline WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil {
return err
}
return c.OK(M{"success": true})
}
func timelineDetail(c *httpapi.Ctx) error {
if err := c.RequireGET(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
id := c.Q("id")
if id == "" || id == "0" {
return c.Err(400, "缺少ID")
}
item, err := c.DB.QueryOne("SELECT * FROM timeline WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if item == nil {
return c.Err(404, "记录不存在")
}
item["images"] = jsonDecodeList(item["images"])
return c.OK(item)
}
+643
View File
@@ -0,0 +1,643 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"strings"
"golang.org/x/crypto/bcrypt"
"past/internal/geo"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
var phoneRe = regexp.MustCompile(`^1[3-9]\d{9}$`)
// hashPassword 对应 password_hash($pw, PASSWORD_DEFAULT),输出 $2y$ 前缀与 PHP 一致
func hashPassword(pw string) string {
h, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return "$2y$" + strings.TrimPrefix(string(h), "$2a$")
}
// verifyPassword 对应 password_verify()
func verifyPassword(pw, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
// last4 对应 substr($s, -4)
func last4(s string) string {
if len(s) <= 4 {
return s
}
return s[len(s)-4:]
}
// ensureUserDirs 创建用户独立文件夹 users/{uid}/{photos,diaries,covers,timeline}
func ensureUserDirs(uid int64) {
base := filepath.Join(up.Dir, "users", fmt.Sprint(uid))
for _, t := range []string{"photos", "diaries", "covers", "timeline"} {
os.MkdirAll(filepath.Join(base, t), 0755)
}
}
func registerUser(r *httpapi.Router) {
r.Handle("register", register)
r.Handle("register_by_invite", registerByInvite)
r.Handle("login", login)
r.Handle("wechat_login", wechatLogin)
r.Handle("check_auth", checkAuth)
r.Handle("logout", logout)
r.Handle("my_info", myInfo)
r.Handle("update_profile", updateProfile)
r.Handle("update_location", updateLocation)
r.Handle("update_ip_location", updateIPLocation)
r.Handle("update_partner_profile", updatePartnerProfile)
r.Handle("upload_avatar", uploadAvatar)
r.Handle("upload_partner_avatar", uploadPartnerAvatar)
}
func register(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
phone := c.InTrim("phone")
password := c.InStr("password")
nickname := c.InTrim("nickname")
if phone == "" {
return c.Err(400, "请输入手机号")
}
if !phoneRe.MatchString(phone) {
return c.Err(400, "请输入正确的手机号")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM users WHERE phone = ? AND phone != ''", phone); ex != nil {
return c.Err(400, "该手机号已注册")
}
if len(password) < 6 {
return c.Err(400, "密码至少6位")
}
if pass, msg := c.App.Wechat.CheckText(nickname, ""); !pass {
return c.Err(400, msg)
}
displayID, err := generateUserId(c.DB)
if err != nil {
return err
}
displayName := nickname
if displayName == "" {
displayName = "用户" + last4(phone)
}
gender := c.InStr("gender")
if gender != "male" && gender != "female" {
gender = ""
}
region := c.InTrim("region")
userID, err := c.DB.Insert("INSERT INTO users (user_id, phone, username, password, nickname, gender, region) VALUES (?, ?, ?, ?, ?, ?, ?)",
displayID, phone, phone, hashPassword(password), displayName, gender, region)
if err != nil {
return err
}
// PHP 每次请求都会执行 install.php 的回填 UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL,
// 效果等价于注册后下一请求即回填;Go 只在启动时执行迁移,因此在此处直接回填
c.DB.Exec("UPDATE users SET last_login_at = created_at WHERE id = ? AND last_login_at IS NULL", userID)
token := httpapi.NewToken()
c.SaveUserToken(userID, token)
// PHP: user_id 来自 lastInsertId(),为字符串
return c.OK(M{"success": true, "user_id": fmt.Sprint(userID), "display_id": displayID, "username": phone, "nickname": displayName, "couple_id": nil, "region": region, "token": token})
}
func registerByInvite(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
phone := c.InTrim("phone")
password := c.InStr("password")
nickname := c.InTrim("nickname")
inviteCode := c.InTrim("invite_code")
if phone == "" {
return c.Err(400, "请输入手机号")
}
if !phoneRe.MatchString(phone) {
return c.Err(400, "请输入正确的手机号")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM users WHERE phone = ? AND phone != ''", phone); ex != nil {
return c.Err(400, "该手机号已注册")
}
if len(password) < 6 {
return c.Err(400, "密码至少6位")
}
if inviteCode == "" {
return c.Err(400, "缺少邀请码")
}
if pass, msg := c.App.Wechat.CheckText(nickname, ""); !pass {
return c.Err(400, msg)
}
link, err := c.DB.QueryOne("SELECT il.*, u.couple_id as inviter_couple_id FROM invite_links il JOIN users u ON il.user_id = u.id WHERE il.code = ? AND il.used = 0", inviteCode)
if err != nil {
return err
}
if link == nil {
return c.Err(400, "邀请链接无效或已使用")
}
if link.Int("inviter_couple_id") != 0 {
return c.Err(400, "邀请人已绑定情侣")
}
displayID, err := generateUserId(c.DB)
if err != nil {
return err
}
displayName := nickname
if displayName == "" {
displayName = "用户" + last4(phone)
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
newUserID, err := tx.Insert("INSERT INTO users (user_id, phone, username, password, nickname) VALUES (?, ?, ?, ?, ?)",
displayID, phone, phone, hashPassword(password), displayName)
if err != nil {
return err
}
inviterID := link.Int("user_id")
coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", inviterID, newUserID)
if err != nil {
return err
}
spaceID := generateCoupleSpaceId(coupleID)
if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", spaceID, coupleID); err != nil {
return err
}
ensureUserDirs(inviterID)
ensureUserDirs(newUserID)
if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, inviterID, newUserID); err != nil {
return err
}
if _, err := tx.Exec("UPDATE invite_links SET used = 1 WHERE id = ?", link.Int("id")); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
createDefaultAlbums(c.DB, coupleID, newUserID)
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", newUserID)
token := httpapi.NewToken()
c.SaveUserToken(newUserID, token)
return c.OK(M{"success": true, "user_id": fmt.Sprint(newUserID), "display_id": displayID, "username": phone, "couple_id": fmt.Sprint(coupleID), "nickname": displayName, "token": token})
}
func login(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
loginField := c.InTrim("phone")
if loginField == "" {
loginField = c.InTrim("username")
}
password := c.InStr("password")
if loginField == "" {
return c.Err(400, "请输入手机号")
}
user, err := c.DB.QueryOne("SELECT * FROM users WHERE username = ? OR phone = ?", loginField, loginField)
if err != nil {
return err
}
if user == nil {
return c.Err(400, "用户不存在,请先注册")
}
if !verifyPassword(password, user.Str("password")) {
return c.Err(401, "用户名或密码错误")
}
token := httpapi.NewToken()
uid := user.Int("id")
c.SaveUserToken(uid, token)
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if loc := resolveLoginLocation(c, ""); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid)
}
if user.Int("couple_id") != 0 {
logActivity(c.DB, user.Int("couple_id"), uid, "login", "登录了小程序", "")
}
return c.OK(M{"success": true, "user_id": user["id"], "display_id": user["user_id"], "username": user["username"], "nickname": user["nickname"],
"couple_id": user["couple_id"], "gender": user.Str("gender"), "avatar": user.Str("avatar"), "token": token})
}
func wechatLogin(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
code := c.InTrim("code")
if code == "" {
return c.Err(400, "缺少登录凭证")
}
openid, wxErr, available := c.App.Wechat.Jscode2Session(code)
if !available {
return c.Err(500, "微信登录服务暂不可用")
}
if wxErr != "" {
return c.Err(400, "微信登录失败: "+wxErr)
}
if openid == "" {
return c.Err(400, "获取用户标识失败")
}
user, err := c.DB.QueryOne("SELECT id, user_id, nickname, avatar, gender, birthday, couple_id, region FROM users WHERE openid = ?", openid)
if err != nil {
return err
}
if user != nil {
uid := user.Int("id")
token := httpapi.NewToken()
c.SaveUserToken(uid, token)
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
region := c.InTrim("region")
if region != "" && user.Str("region") == "" {
c.DB.Exec("UPDATE users SET region = ? WHERE id = ?", region, uid)
user["region"] = region
}
if loc := resolveLoginLocation(c, region); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid)
}
if cid := user.Int("couple_id"); cid != 0 {
actID := logActivity(c.DB, cid, uid, "login", "登录了小程序", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
nick := user.Str("nickname")
if nick == "" {
nick = "你的TA"
}
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": nick + " 登录了小程序",
"thing2": "TA已上线,快来互动吧 💕",
"date3": today(),
"thing4": "点击查看详情",
}, "pages/index/index", actID, uid)
}
}
return c.OK(M{
"success": true, "is_new": false,
"user_id": user["id"], "display_id": user["user_id"],
"couple_id": user["couple_id"], "nickname": user["nickname"],
"avatar": user.Str("avatar"), "gender": user.Str("gender"),
"birthday": user.Str("birthday"), "region": user.Str("region"),
"token": token,
})
}
displayID, err := generateUserId(c.DB)
if err != nil {
return err
}
region := c.InTrim("region")
defaultNick := "用户" + last4(displayID)
newID, err := c.DB.Insert("INSERT INTO users (user_id, openid, username, password, nickname, region) VALUES (?, ?, ?, ?, ?, ?)",
displayID, openid, "wx_"+openid, hashPassword(randomHex(8)), defaultNick, region)
if err != nil {
return err
}
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", newID)
if loc := resolveLoginLocation(c, region); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, newID)
}
token := httpapi.NewToken()
c.SaveUserToken(newID, token)
return c.OK(M{
"success": true, "is_new": true,
"user_id": fmt.Sprint(newID), "display_id": displayID,
"couple_id": nil, "nickname": defaultNick,
"avatar": "", "gender": "", "birthday": "", "region": region,
"token": token,
})
}
func checkAuth(c *httpapi.Ctx) error {
if err := c.DB.AdvanceUnbindings(); err != nil {
return err
}
c.CoupleID()
uid := c.UID()
if uid == 0 {
return c.OK(M{"logged_in": false})
}
// 触发登录记录(5 分钟去重)——逻辑与 requireUserLogin 相同
if user, _ := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid); user != nil {
if needLoginRecord(user.Str("last_login_at")) {
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if user.Int("couple_id") != 0 {
c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Int("couple_id"), uid)
}
}
}
user, err := c.DB.QueryOne("SELECT id as user_id, user_id as display_id, phone, username, nickname, avatar, gender, couple_id FROM users WHERE id = ?", uid)
if err != nil {
return err
}
return c.OK(M{"logged_in": true, "user": user})
}
func logout(c *httpapi.Ctx) error {
if uid := c.UID(); uid != 0 {
// PHP 只销毁 session(token 仍可通过 user_tokens 兜底);Go 版本同时失效 token,语义更合理
c.DB.Exec("DELETE FROM user_tokens WHERE token = ?", c.Token())
}
return c.OK(M{"success": true})
}
func myInfo(c *httpapi.Ctx) error {
uid := c.UID()
user, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.couple_id, u.created_at FROM users u WHERE u.id = ?", uid)
if err != nil {
return err
}
if user == nil {
return c.OK(false)
}
if user.Int("couple_id") != 0 {
partner, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.lat, u.lng FROM users u INNER JOIN couples c ON (u.id = c.user1_id OR u.id = c.user2_id) WHERE c.id = ? AND u.id != ?", user.Int("couple_id"), uid)
if err != nil {
return err
}
if partner == nil {
user["partner"] = false
} else {
user["partner"] = partner
}
}
return c.OK(user)
}
func updateProfile(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
nickname := c.InTrim("nickname")
avatar := c.InStr("avatar")
gender := c.InStr("gender")
if nickname != "" {
if pass, msg := c.App.Wechat.CheckText(nickname, userOpenId(c.DB, uid)); !pass {
return c.Err(400, msg)
}
}
hasAge := c.InHas("age")
age := c.InInt("age")
birthday := c.InTrim("birthday")
region := c.InTrim("region")
var fields []string
var params []any
var changed []string
if store.Truthy(nickname) {
fields = append(fields, "nickname = ?")
params = append(params, nickname)
changed = append(changed, "昵称")
}
if store.Truthy(avatar) {
fields = append(fields, "avatar = ?")
params = append(params, avatar)
changed = append(changed, "头像")
}
if store.Truthy(gender) {
fields = append(fields, "gender = ?")
params = append(params, gender)
changed = append(changed, "性别")
}
if hasAge {
fields = append(fields, "age = ?")
params = append(params, age)
changed = append(changed, "年龄")
}
if birthday != "" {
fields = append(fields, "birthday = ?")
params = append(params, birthday)
changed = append(changed, "生日")
}
if region != "" {
fields = append(fields, "region = ?")
params = append(params, region)
changed = append(changed, "地区")
}
if len(fields) > 0 {
fields = append(fields, "updated_at = CURRENT_TIMESTAMP")
params = append(params, uid)
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err
}
if cid := c.CoupleID(); cid != 0 && len(changed) > 0 {
logActivity(c.DB, cid, uid, "profile_edit", "修改了个人资料:"+strings.Join(changed, "、"), "")
}
}
return c.OK(M{"success": true})
}
func updateLocation(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
location := c.InTrim("location")
if location == "" {
return c.Err(400, "位置不能为空")
}
saved := location
var lat, lng float64
if strings.Contains(location, ",") {
parts := strings.Split(location, ",")
if len(parts) == 2 && isNumeric(strings.TrimSpace(parts[0])) && isNumeric(strings.TrimSpace(parts[1])) {
lat = store.ToFloat(strings.TrimSpace(parts[0]))
lng = store.ToFloat(strings.TrimSpace(parts[1]))
if addr := c.App.Geo.ReverseGeocode(lat, lng); addr != "" {
saved = addr
} else {
old, _ := c.DB.QueryOne("SELECT location FROM users WHERE id = ?", uid)
if old != nil && old.Str("location") != "" && !strings.Contains(old.Str("location"), ",") {
saved = old.Str("location")
} else {
saved = ""
}
}
}
}
if _, err := c.DB.Exec("UPDATE users SET location = ?, lat = ?, lng = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", saved, lat, lng, uid); err != nil {
return err
}
var distance any
if cid := c.CoupleID(); cid != 0 {
partner, _ := c.DB.QueryOne("SELECT lat, lng FROM users WHERE couple_id = ? AND id != ? AND lat > 0 AND lng > 0", cid, uid)
if partner != nil && lat != 0 && lng != 0 {
distance = geo.Haversine(lat, lng, store.ToFloat(partner["lat"]), store.ToFloat(partner["lng"]))
}
}
return c.OK(M{"success": true, "location": saved, "lat": lat, "lng": lng, "distance": distance})
}
func updateIPLocation(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
var newLat, newLng float64
if c.InHas("lat") {
newLat = c.InFloat("lat")
}
if c.InHas("lng") {
newLng = c.InFloat("lng")
}
u, _ := c.DB.QueryOne("SELECT location, lat, lng, last_ip FROM users WHERE id = ?", uid)
current := ""
var oldLat, oldLng float64
if u != nil {
current = u.Str("location")
oldLat = store.ToFloat(u["lat"])
oldLng = store.ToFloat(u["lng"])
}
if newLat != 0 && newLng != 0 && oldLat != 0 && oldLng != 0 {
dist := geo.Haversine(oldLat, oldLng, newLat, newLng)
if dist <= 5.0 {
return c.OK(M{"success": true, "location": current, "cached": true, "distance": math.Round(dist*100) / 100})
}
}
clientIP := c.ClientIP()
address := ""
if newLat != 0 && newLng != 0 {
address = c.App.Geo.ReverseGeocode(newLat, newLng)
}
if address == "" {
address = c.App.Geo.IPGeolocate(clientIP)
}
if address != "" {
if _, err := c.DB.Exec("UPDATE users SET location = ?, lat = ?, lng = ?, last_ip = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", address, newLat, newLng, clientIP, uid); err != nil {
return err
}
return c.OK(M{"success": true, "location": address, "cached": false})
}
return c.OK(M{"success": true, "location": current, "cached": true})
}
// verifyPartner 校验 partner_id 是当前用户的伴侣;失败时已写出响应
func verifyPartner(c *httpapi.Ctx, uid, partnerID int64) error {
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if user == nil || user.Int("couple_id") == 0 {
return c.Err(400, "你还没有绑定情侣")
}
partner, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ? AND couple_id = ?", partnerID, user.Int("couple_id"))
if partner == nil {
return c.Err(400, "无法修改此用户信息")
}
return nil
}
func updatePartnerProfile(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
partnerID := c.InInt("partner_id")
nickname := c.InTrim("nickname")
avatar := c.InStr("avatar")
gender := c.InTrim("gender")
hasAge := c.InHas("age")
age := c.InInt("age")
birthday := c.InTrim("birthday")
if err := verifyPartner(c, uid, partnerID); err != nil {
return err
}
var fields []string
var params []any
var changed []string
if store.Truthy(nickname) {
fields = append(fields, "nickname = ?")
params = append(params, nickname)
changed = append(changed, "昵称")
}
if store.Truthy(avatar) {
fields = append(fields, "avatar = ?")
params = append(params, avatar)
changed = append(changed, "头像")
}
if store.Truthy(gender) {
fields = append(fields, "gender = ?")
params = append(params, gender)
changed = append(changed, "性别")
}
if hasAge {
fields = append(fields, "age = ?")
params = append(params, age)
changed = append(changed, "年龄")
}
if birthday != "" {
fields = append(fields, "birthday = ?")
params = append(params, birthday)
changed = append(changed, "生日")
}
if len(fields) > 0 {
fields = append(fields, "updated_at = CURRENT_TIMESTAMP")
params = append(params, partnerID)
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err
}
if cid := c.CoupleID(); cid != 0 && len(changed) > 0 {
logActivity(c.DB, cid, uid, "partner_edit", "修改了TA的资料:"+strings.Join(changed, "、"), "")
}
}
return c.OK(M{"success": true})
}
// doAvatarUpload 头像上传公共逻辑;文件字段兼容 avatar / file
func doAvatarUpload(c *httpapi.Ctx, targetUID int64) error {
u, _ := c.DB.QueryOne("SELECT user_id FROM users WHERE id = ?", targetUID)
uidPath := fmt.Sprintf("uid%d", targetUID)
if u != nil {
uidPath = u.Str("user_id")
}
fh := c.File("avatar")
if fh == nil {
fh = c.File("file")
}
if fh == nil {
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
}
res := up.Handle(fh, "avatars", upload.AvatarPath(uidPath))
if res.Path != "" {
if _, err := c.DB.Exec("UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", res.Path, targetUID); err != nil {
return err
}
return c.OK(M{"success": true, "path": res.Path})
}
if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
}
msg := res.Error
if msg == "" {
msg = "未知错误"
}
return c.ErrCode(400, "上传失败: "+msg, upload.ErrHandleFailed)
}
func uploadAvatar(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
return doAvatarUpload(c, c.UID())
}
func uploadPartnerAvatar(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
uid := c.UID()
partnerID := store.ToInt(c.Form("partner_id"))
if err := verifyPartner(c, uid, partnerID); err != nil {
return err
}
return doAvatarUpload(c, partnerID)
}
+192
View File
@@ -0,0 +1,192 @@
package httpapi
import (
"crypto/rand"
"encoding/hex"
"net/http"
"regexp"
"sync"
"time"
)
var bearerRe = regexp.MustCompile(`Bearer\s(\S+)`)
// Token 对应 getBearerToken() + $_POST['token'] + $_GET['token'] 三通道
func (c *Ctx) Token() string {
if m := bearerRe.FindStringSubmatch(c.G.GetHeader("Authorization")); m != nil {
return m[1]
}
if t := c.Form("token"); t != "" {
return t
}
if t := c.Q("token"); t != "" {
return t
}
return ""
}
// UID 对应 getUserId():通过 token 查 user_tokens;未登录返回 0。
func (c *Ctx) UID() int64 {
if c.uidResolved {
return c.uid
}
c.uidResolved = true
tok := c.Token()
if tok == "" {
return 0
}
row, err := c.DB.QueryOne("SELECT user_id FROM user_tokens WHERE token = ? AND expires_at > NOW()", tok)
if err != nil || row == nil {
return 0
}
c.uid = row.Int("user_id")
return c.uid
}
// CoupleID 对应 getCoupleId();无则返回 0
func (c *Ctx) CoupleID() int64 {
if c.coupleResolved {
return c.coupleID
}
c.coupleResolved = true
uid := c.UID()
if uid == 0 {
return 0
}
row, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if row == nil {
return 0
}
c.coupleID = row.Int("couple_id")
return c.coupleID
}
// NewToken 生成 32 位 hex token(与 PHP session_id 格式一致)
func NewToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// SaveUserToken 对应 saveUserToken(),写入新 token(7 天)并清理过期 token。
// PHP 会删除该用户旧 token,但旧 session 文件仍然有效(实际上允许多端同时登录);
// Go 没有 session 文件,因此保留旧 token 以维持同样的多端登录效果。
func (c *Ctx) SaveUserToken(userID int64, token string) {
c.DB.Exec("DELETE FROM user_tokens WHERE expires_at < NOW()")
c.DB.Exec("INSERT INTO user_tokens (token, user_id, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))", token, userID)
}
// requireUserLogin 对应 requireUserLogin():401/404,并做 5 分钟去重的登录记录
func (c *Ctx) requireUserLogin() error {
uid := c.UID()
if uid == 0 {
return c.Err(401, "请先登录")
}
if err := c.DB.AdvanceUnbindings(); err != nil {
return err
}
user, err := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid)
if err != nil {
return err
}
if user == nil {
return c.Err(404, "用户不存在")
}
needRecord := false
if user.IsNull("last_login_at") || user.Str("last_login_at") == "" {
needRecord = true
} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", user.Str("last_login_at"), time.Local); err != nil || time.Since(t) > 300*time.Second {
needRecord = true
}
if needRecord {
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if user.Int("couple_id") != 0 {
c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Int("couple_id"), uid)
}
}
return nil
}
// ---------- 后台管理员会话(对应 PHP Cookie Session)----------
const adminCookieName = "PHPSESSID"
const adminSessionTTL = 12 * time.Hour
type adminSession struct {
AdminID int64
Username string
Expires time.Time
}
type AdminSessions struct {
mu sync.Mutex
m map[string]*adminSession
}
func NewAdminSessions() *AdminSessions {
s := &AdminSessions{m: map[string]*adminSession{}}
go func() {
for range time.Tick(10 * time.Minute) {
s.mu.Lock()
now := time.Now()
for k, v := range s.m {
if v.Expires.Before(now) {
delete(s.m, k)
}
}
s.mu.Unlock()
}
}()
return s
}
func (s *AdminSessions) get(id string) *adminSession {
s.mu.Lock()
defer s.mu.Unlock()
v := s.m[id]
if v == nil || v.Expires.Before(time.Now()) {
delete(s.m, id)
return nil
}
v.Expires = time.Now().Add(adminSessionTTL)
return v
}
// AdminSession 当前请求的管理员会话;未登录返回 nil
func (c *Ctx) AdminSession() *adminSession {
id, err := c.G.Cookie(adminCookieName)
if err != nil || id == "" {
return nil
}
return c.App.Admin.get(id)
}
// AdminLogin 建立管理员会话并写 Cookie(对应 session_start + $_SESSION['admin_logged_in']=true)
func (c *Ctx) AdminLogin(adminID int64, username string) {
id := NewToken()
c.App.Admin.mu.Lock()
c.App.Admin.m[id] = &adminSession{AdminID: adminID, Username: username, Expires: time.Now().Add(adminSessionTTL)}
c.App.Admin.mu.Unlock()
c.G.SetSameSite(http.SameSiteLaxMode)
c.G.SetCookie(adminCookieName, id, 0, "/", "", false, true)
}
// AdminLogout 对应 session_destroy()
func (c *Ctx) AdminLogout() {
if id, err := c.G.Cookie(adminCookieName); err == nil {
c.App.Admin.mu.Lock()
delete(c.App.Admin.m, id)
c.App.Admin.mu.Unlock()
}
c.G.SetCookie(adminCookieName, "", -1, "/", "", false, true)
}
// requireAdminLogin 对应 requireAdminLogin()
func (c *Ctx) requireAdminLogin() error {
if c.AdminSession() == nil {
return c.Err(401, "未登录或登录已过期")
}
return nil
}
func (c *Ctx) SetCoupleID(cid int64) { c.coupleID = cid; c.coupleResolved = true }
+326
View File
@@ -0,0 +1,326 @@
// Package httpapi 提供与 PHP 版 index.php 等价的请求上下文、路由分发、鉴权和响应封装。
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"past/internal/config"
"past/internal/geo"
"past/internal/store"
"past/internal/wechat"
)
// App 全局依赖
type App struct {
Cfg *config.Config
DB *store.DB
Wechat *wechat.Client
Geo *geo.Client
Admin *AdminSessions
}
// Ctx 单次请求上下文,对应 PHP 中的全局变量 $_GET/$_POST/$input/$_FILES 与 getUserId()。
type Ctx struct {
G *gin.Context
W http.ResponseWriter
R *http.Request
App *App
DB *store.DB
Action string
CamelCase bool
Method string
input map[string]any
inputParsed bool
body []byte
bodyRead bool
uid int64
uidResolved bool
coupleID int64
coupleResolved bool
responded bool
}
// errResponded 表示响应已写出(对应 PHP 的 exit)。
var errResponded = errors.New("responded")
// IsResponded 判断错误是否为“已响应”哨兵
func IsResponded(err error) bool { return errors.Is(err, errResponded) }
// ---------- 响应 ----------
// JSON 对应 jsonResponse($data, $code)。不用 gin 自带的 c.JSON(它会把 <>& 转义成 \u003c),
// 而是自行编码后通过 c.Data 输出,保持与 PHP json_encode(JSON_UNESCAPED_UNICODE) 一致。
func (c *Ctx) JSON(code int, v any) error {
if c.responded {
return errResponded
}
c.responded = true
if c.uidResolved && c.uid != 0 && c.coupleResolved {
c.G.Header("X-Couple-ID", fmt.Sprint(c.coupleID))
c.G.Header("X-User-ID", fmt.Sprint(c.uid))
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
c.G.Data(500, "application/json; charset=utf-8", []byte(`{"error":"json encode failed"}`))
return errResponded
}
if c.CamelCase {
var payload any
dec := json.NewDecoder(bytes.NewReader(buf.Bytes()))
dec.UseNumber()
if err := dec.Decode(&payload); err != nil {
return err
}
if c.CamelCase {
payload = renameKeys(payload, camelName)
}
buf.Reset()
if err := enc.Encode(payload); err != nil {
return err
}
}
c.G.Data(code, "application/json; charset=utf-8", bytes.TrimRight(buf.Bytes(), "\n"))
return errResponded
}
// OK 返回 200
func (c *Ctx) OK(v any) error { return c.JSON(200, v) }
// Err 对应 jsonResponse(['error' => $msg], $code)
func (c *Ctx) Err(code int, msg string) error {
return c.JSON(code, M{"error": msg})
}
// ErrCode 带错误码的上传错误:['error' => msg, 'code' => n]
func (c *Ctx) ErrCode(code int, msg string, errCode int) error {
return c.JSON(code, M{"error": msg, "code": errCode})
}
// M 便捷的 JSON 对象类型
type M = map[string]any
// RequirePOST 对应 if ($method !== 'POST') jsonResponse(['error' => '方法不允许'], 405);
func (c *Ctx) RequirePOST() error {
if c.Method != "POST" {
return c.Err(405, "方法不允许")
}
return nil
}
func (c *Ctx) RequireGET() error {
if c.Method != "GET" {
return c.Err(405, "方法不允许")
}
return nil
}
// ---------- 请求体 / 参数 ----------
// Body 原始请求体(php://input)
func (c *Ctx) Body() []byte {
if !c.bodyRead {
c.bodyRead = true
if c.R.Body != nil {
c.body, _ = io.ReadAll(c.R.Body)
}
}
return c.body
}
// Input 对应 $input = json_decode(file_get_contents('php://input'), true);解析失败返回空 map。
func (c *Ctx) Input() map[string]any {
if !c.inputParsed {
c.inputParsed = true
c.input = map[string]any{}
// Multipart fields belong to Form; reading them as JSON would consume the upload stream.
if c.isForm() {
return c.input
}
b := c.Body()
if len(bytes.TrimSpace(b)) > 0 {
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var v any
if dec.Decode(&v) == nil {
if m, ok := v.(map[string]any); ok {
if c.CamelCase {
m = renameKeys(m, snakeName).(map[string]any)
}
c.input = normalizeNumbers(m).(map[string]any)
}
}
}
}
return c.input
}
// normalizeNumbers 把 json.Number 转成 int64 或 float64(整数保持整数,对齐 PHP json_decode)
func normalizeNumbers(v any) any {
switch x := v.(type) {
case json.Number:
if i, err := x.Int64(); err == nil {
return i
}
f, _ := x.Float64()
return f
case map[string]any:
for k, val := range x {
x[k] = normalizeNumbers(val)
}
return x
case []any:
for i, val := range x {
x[i] = normalizeNumbers(val)
}
return x
}
return v
}
// In 对应 $input['key'] ?? nil
func (c *Ctx) In(key string) any { return normalizeMediaInput(c.Input()[key]) }
// InHas 对应 isset($input['key'])
func (c *Ctx) InHas(key string) bool {
v, ok := c.Input()[key]
return ok && v != nil
}
// InStr 对应 (string)($input['key'] ?? ”)
func (c *Ctx) InStr(key string) string { return store.ToStr(c.In(key)) }
// InTrim 对应 trim($input['key'] ?? ”)
func (c *Ctx) InTrim(key string) string { return strings.TrimSpace(c.InStr(key)) }
// InInt 对应 intval($input['key'] ?? 0)
func (c *Ctx) InInt(key string) int64 { return store.ToInt(c.In(key)) }
// InFloat 对应 floatval($input['key'] ?? 0)
func (c *Ctx) InFloat(key string) float64 { return store.ToFloat(c.In(key)) }
// InBool 对应 PHP 真值判断
func (c *Ctx) InBool(key string) bool { return store.Truthy(c.In(key)) }
// InList 对应 is_array($input['key']) ? $input['key'] : []
func (c *Ctx) InList(key string) []any {
if l, ok := c.In(key).([]any); ok {
return l
}
return []any{}
}
// InMap 对应关联数组
func (c *Ctx) InMap(key string) map[string]any {
if m, ok := c.In(key).(map[string]any); ok {
return m
}
return nil
}
// Q 对应 $_GET['key'] ?? ”
func (c *Ctx) Q(key string) string {
if c.CamelCase {
if value, ok := c.G.GetQuery(camelName(key)); ok {
return value
}
}
return c.G.Query(key)
}
// QHas 对应 isset($_GET['key'])
func (c *Ctx) QHas(key string) bool {
if c.CamelCase {
if _, ok := c.G.GetQuery(camelName(key)); ok {
return true
}
}
_, ok := c.G.GetQuery(key)
return ok
}
// QInt 对应 intval($_GET['key'] ?? 0)
func (c *Ctx) QInt(key string) int64 { return store.ToInt(c.Q(key)) }
// Form 对应 $_POST['key'] ?? ”(表单/multipart 字段)
func (c *Ctx) Form(key string) string {
if !c.isForm() {
return ""
}
if c.CamelCase {
if value, ok := c.G.GetPostForm(camelName(key)); ok {
return value
}
}
return c.G.PostForm(key)
}
// FormHas 对应 isset($_POST['key'])
func (c *Ctx) FormHas(key string) bool {
if !c.isForm() {
return false
}
if c.CamelCase {
if _, ok := c.G.GetPostForm(camelName(key)); ok {
return true
}
}
_, ok := c.G.GetPostForm(key)
return ok
}
// isForm 只有表单/multipart 请求才解析 $_POST(其它类型的 body 留给 Input() 读取 JSON)
func (c *Ctx) isForm() bool {
ct := c.R.Header.Get("Content-Type")
return strings.HasPrefix(ct, "multipart/form-data") || strings.HasPrefix(ct, "application/x-www-form-urlencoded")
}
// File 对应 $_FILES['name'](单文件);不存在返回 nil。
func (c *Ctx) File(name string) *multipart.FileHeader {
fs := c.Files(name)
if len(fs) == 0 {
return nil
}
return fs[0]
}
// Files 对应 $_FILES['name']:与 PHP 一致,同名字段 name 重复时只保留最后一个;name[] 才是多文件数组
func (c *Ctx) Files(name string) []*multipart.FileHeader {
if !strings.HasPrefix(c.R.Header.Get("Content-Type"), "multipart/form-data") {
return nil
}
form, err := c.G.MultipartForm()
if err != nil || form == nil {
return nil
}
if fs := form.File[name]; len(fs) > 0 {
return fs[len(fs)-1:]
}
return form.File[name+"[]"]
}
// ClientIP 对应 getClientIp():X-Forwarded-For → X-Real-IP → RemoteAddr
// (gin 自带的 ClientIP 只信任 TrustedProxies,这里保持 PHP 的宽松取法,Nginx/frp 链路上由 Nginx 设置头)
func (c *Ctx) ClientIP() string {
if xff := c.G.GetHeader("X-Forwarded-For"); xff != "" {
ip := strings.TrimSpace(strings.Split(xff, ",")[0])
if ip != "" && ip != "unknown" {
return ip
}
}
if xr := strings.TrimSpace(c.G.GetHeader("X-Real-Ip")); xr != "" && xr != "unknown" {
return xr
}
return c.G.RemoteIP()
}
+47
View File
@@ -0,0 +1,47 @@
package httpapi
import (
"encoding/json"
"net/url"
"strings"
)
func normalizeMediaPath(value string) string {
parsed, err := url.Parse(value)
if err != nil {
return value
}
if strings.HasPrefix(parsed.Path, "/backend/uploads/") {
return strings.TrimPrefix(parsed.Path, "/backend/")
}
if !parsed.IsAbs() && parsed.Host == "" && strings.HasPrefix(parsed.Path, "uploads/") {
return parsed.Path
}
return value
}
func normalizeMediaInput(value any) any {
switch v := value.(type) {
case map[string]any:
for k, item := range v {
v[k] = normalizeMediaInput(item)
}
return v
case []any:
for i, item := range v {
v[i] = normalizeMediaInput(item)
}
return v
case string:
if strings.HasPrefix(v, "[") {
var list []any
if json.Unmarshal([]byte(v), &list) == nil {
data, _ := json.Marshal(normalizeMediaInput(list))
return string(data)
}
}
return normalizeMediaPath(v)
}
return value
}
+48
View File
@@ -0,0 +1,48 @@
package httpapi
import (
"github.com/gin-gonic/gin"
"net/http/httptest"
"net/url"
"os"
"past/internal/config"
"path/filepath"
"strings"
"testing"
)
func TestLegacyPhotoResponsesKeepTheirOriginalPaths(t *testing.T) {
out := httptest.NewRecorder()
g, _ := gin.CreateTestContext(out)
g.Request = httptest.NewRequest("GET", "/api/photos", nil)
c := &Ctx{G: g, R: g.Request, CamelCase: true}
c.OK(M{"path": "uploads/users/1/photos/a.jpg"})
if !strings.Contains(out.Body.String(), `"path":"uploads/users/1/photos/a.jpg"`) {
t.Fatal(out.Body.String())
}
}
func TestPublishedUnsignedPhotoURLsStillLoad(t *testing.T) {
dir := t.TempDir()
folder := filepath.Join(dir, "uploads", "users", "1")
if err := os.MkdirAll(folder, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(folder, "a.jpg"), []byte("photo"), 0600); err != nil {
t.Fatal(err)
}
router := NewRouter(&App{Cfg: &config.Config{DataDir: dir}}, nil).Engine(false)
out := httptest.NewRecorder()
router.ServeHTTP(out, httptest.NewRequest("GET", "/backend/uploads/users/1/a.jpg", nil))
if out.Code != 200 || out.Body.String() != "photo" {
t.Fatalf("code=%d body=%s", out.Code, out.Body.String())
}
}
func TestSignedMediaReturnsToCanonicalStoragePath(t *testing.T) {
resource := "uploads/users/1/diaries/a.jpg"
signed := "https://past.sundynix.cn/backend/" + resource + "?" + url.Values{"media_sig": {"test"}, "media_uid": {"1"}}.Encode()
input := map[string]any{"images": []any{signed}, "encoded": "[\"" + signed + "\"]", "content": "plain text"}
normalized := normalizeMediaInput(input).(map[string]any)
if normalized["images"].([]any)[0] != resource || normalized["encoded"] != "[\""+resource+"\"]" || normalized["content"] != "plain text" {
t.Fatalf("wrong normalization: %v", normalized)
}
}
+48
View File
@@ -0,0 +1,48 @@
package httpapi
import "strings"
// camelName is the public API naming convention; DB and handlers keep their established names.
func camelName(s string) string {
parts := strings.Split(s, "_")
for i := 1; i < len(parts); i++ {
if len(parts[i]) > 0 {
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
}
}
return strings.Join(parts, "")
}
func snakeName(s string) string {
var b strings.Builder
for i, c := range s {
if c >= 'A' && c <= 'Z' {
if i > 0 {
b.WriteByte('_')
}
b.WriteRune(c + ('a' - 'A'))
} else {
b.WriteRune(c)
}
}
return b.String()
}
func renameKeys(v any, name func(string) string) any {
switch x := v.(type) {
case map[string]any:
result := make(map[string]any, len(x))
for k, val := range x {
result[name(k)] = renameKeys(val, name)
}
return result
case []any:
result := make([]any, len(x))
for i, val := range x {
result[i] = renameKeys(val, name)
}
return result
default:
return v
}
}
+243
View File
@@ -0,0 +1,243 @@
package httpapi
import (
"bytes"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"runtime/debug"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// HandlerFunc 一个 action 的处理函数。返回 errResponded(由 c.JSON 产生)表示正常结束;
// 返回其它 error 对应 PHP 顶层 catch → 500 {"error": message}。
type HandlerFunc func(c *Ctx) error
type Router struct {
app *App
handlers map[string]HandlerFunc
adminFS fs.FS // React 后台构建产物(embed)
}
func NewRouter(app *App, adminFS fs.FS) *Router {
return &Router{app: app, handlers: map[string]HandlerFunc{}, adminFS: adminFS}
}
// Handle 注册 action
func (r *Router) Handle(action string, h HandlerFunc) {
if _, dup := r.handlers[action]; dup {
panic("duplicate action: " + action)
}
r.handlers[action] = h
}
// Actions 已注册的 action 列表(用于自检)
func (r *Router) Actions() []string {
out := make([]string, 0, len(r.handlers))
for k := range r.handlers {
out = append(out, k)
}
return out
}
// 与 index.php 中 $publicActions / $adminActions 完全一致
var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login",
"check_invite_code", "register_by_invite", "check_auth")
var adminActions = set("admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations", "admin_subscribe_logs")
func set(items ...string) map[string]bool {
m := make(map[string]bool, len(items))
for _, s := range items {
m[s] = true
}
return m
}
var actionInURIRe = regexp.MustCompile(`[?&]action=([^&]+)`)
// Engine 构建 gin 引擎:API 入口、上传文件静态服务、后台 SPA。
func (r *Router) Engine(debugMode bool) *gin.Engine {
if debugMode {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
e := gin.New()
e.Use(gin.Recovery(), accessLog())
e.RedirectTrailingSlash = false
e.MaxMultipartMemory = 32 << 20
// 简洁 API 路径;旧入口保留给已发布客户端和管理后台。
e.Any("/api", r.serveAPI)
e.Any("/api/", r.serveAPI)
e.Any("/api/:action", r.serveAPI)
e.Any("/backend/api/index.php", r.serveAPI)
e.Any("/backend/api/", r.serveAPI)
e.Any("/backend/api", r.serveAPI)
// 上传文件:DB 中路径 uploads/... → URL /backend/uploads/...(gin.Dir 关闭目录列表;缓存 1 个月,对应 .htaccess)
uploadDir := r.app.Cfg.UploadDir()
uploads := e.Group("/backend/uploads", func(c *gin.Context) {
// 只对真实存在的文件加长缓存;404 不能被浏览器缓存(否则文件补上后仍显示不出来)
p := filepath.Join(uploadDir, filepath.FromSlash(strings.TrimPrefix(c.Param("filepath"), "/")))
if st, err := os.Stat(p); err == nil && !st.IsDir() && !strings.Contains(c.Param("filepath"), "..") {
c.Header("Cache-Control", "public, max-age=2592000")
} else {
c.Header("Cache-Control", "no-store")
}
})
uploads.StaticFS("/", gin.Dir(uploadDir, false))
// 管理后台 SPA
e.GET("/backend/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/backend/admin/") })
e.GET("/backend/admin/*filepath", r.serveAdmin)
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
return e
}
func (r *Router) serveAPI(g *gin.Context) {
w, req := g.Writer, g.Request
// CORS(与 PHP 一致)
h := w.Header()
// API 响应按用户身份变化,且权限可能随时修改,不能复用旧的 GET 结果。
h.Set("Cache-Control", "no-store")
h.Set("Access-Control-Allow-Origin", "*")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if req.Method == http.MethodOptions {
w.WriteHeader(200)
return
}
c := &Ctx{G: g, W: w, R: req, App: r.app, DB: r.app.DB, Method: req.Method}
g.Set("action", "")
// 路径中的 action 优先,兼容旧客户端的查询和表单参数。
action := g.Param("action")
if action == "" {
action = req.URL.Query().Get("action")
}
if action == "" {
action = c.Form("action")
}
if action == "" {
if m := actionInURIRe.FindStringSubmatch(req.RequestURI); m != nil {
if v, err := url.QueryUnescape(m[1]); err == nil {
action = v
}
}
}
// A camelCase path opts into camelCase request/response fields. Snake paths retain legacy payloads.
publicAction := action
c.CamelCase = g.Param("action") != "" && !strings.Contains(action, "_")
action = snakeName(action)
c.Action = action
g.Set("action", publicAction)
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic", "action", action, "err", rec, "stack", string(debug.Stack()))
if !c.responded {
c.Err(500, fmt.Sprint(rec))
}
}
g.Set("uid", c.uid)
}()
if action == "" {
c.JSON(200, M{
"name": "我们past API",
"version": "2.0.0",
"publicEndpoints": []string{"register", "login", "wechatLogin", "siteInfo", "siteStats", "adminLogin", "checkInviteCode", "registerByInvite"},
})
return
}
// 权限检查(与 PHP 的 if/elseif 顺序一致)
var err error
if adminActions[action] && action != "admin_login" {
err = c.requireAdminLogin()
} else if !publicActions[action] && !adminActions[action] {
err = c.requireUserLogin()
}
if err != nil {
if !IsResponded(err) {
c.Err(500, err.Error())
}
return
}
hf, ok := r.handlers[action]
if !ok {
c.Err(400, "未知操作")
return
}
if err := hf(c); err != nil && !IsResponded(err) {
slog.Error("handler error", "action", action, "err", err)
c.Err(500, err.Error())
}
if !c.responded {
// PHP 中每个分支都以 jsonResponse 结束;兜底返回空对象,避免挂起
c.JSON(200, M{})
}
}
// accessLog gin 中间件:结构化访问日志(slog)
func accessLog() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
attrs := []any{"method", c.Request.Method, "path", c.Request.URL.Path, "status", c.Writer.Status(), "ms", time.Since(start).Milliseconds()}
if a, ok := c.Get("action"); ok {
attrs = append(attrs, "action", a)
}
if uid, ok := c.Get("uid"); ok {
attrs = append(attrs, "uid", uid)
}
slog.Info("http", attrs...)
}
}
// serveAdmin 提供 embed 的后台 SPA;未命中的路径回退到 index.html
func (r *Router) serveAdmin(g *gin.Context) {
p := strings.TrimPrefix(path.Clean("/"+g.Param("filepath")), "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(r.adminFS, p); err != nil {
p = "index.html"
}
if strings.HasSuffix(p, ".html") {
g.Header("Cache-Control", "no-cache")
}
// 不用 http.FileServer:它会把 /index.html 301 到 ./
f, err := r.adminFS.Open(p)
if err != nil {
g.Status(404)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
g.Status(404)
return
}
rs, ok := f.(io.ReadSeeker)
if !ok {
b, _ := io.ReadAll(f)
rs = bytes.NewReader(b)
}
http.ServeContent(g.Writer, g.Request, p, st.ModTime(), rs)
}
+127
View File
@@ -0,0 +1,127 @@
package httpapi
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"past/internal/config"
)
func TestCleanAPIRoutes(t *testing.T) {
app := &App{Cfg: &config.Config{DataDir: t.TempDir()}, Admin: NewAdminSessions()}
router := NewRouter(app, fstest.MapFS{"index.html": {Data: []byte("admin")}})
router.Handle("site_info", func(c *Ctx) error { return c.JSON(200, M{"action": c.Action, "value": c.Q("value")}) })
router.Handle("admin_login", func(c *Ctx) error {
c.AdminLogin(1, "test")
return c.JSON(200, M{"success": true})
})
router.Handle("admin_stats", func(c *Ctx) error { return c.JSON(200, M{"success": true}) })
router.Handle("site_stats", func(c *Ctx) error {
return c.JSON(200, M{"value": c.Form("value"), "token": c.Token()})
})
engine := router.Engine(false)
for _, uri := range []string{"/api/site_info?value=hello", "/api/site_info?action=wrong&value=hello", "/backend/api/index.php?action=site_info&value=hello"} {
t.Run(uri, func(t *testing.T) {
out := httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodGet, uri, nil))
var result map[string]any
if err := json.Unmarshal(out.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if out.Code != 200 || result["action"] != "site_info" || result["value"] != "hello" {
t.Fatalf("%d %s", out.Code, out.Body.String())
}
if out.Header().Get("Cache-Control") != "no-store" {
t.Fatal("API responses must not be cached")
}
})
}
for _, uri := range []string{"/api/couple_info", "/api/period_visible_set", "/api/upload", "/api/admin_stats", "/api/adminSubscribeLogs"} {
out := httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodPost, uri, strings.NewReader("{}")))
if out.Code != 401 {
t.Fatalf("unauthenticated %s: %d %s", uri, out.Code, out.Body.String())
}
}
out := httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodOptions, "/api/upload", nil))
if out.Code != 200 || out.Header().Get("Access-Control-Allow-Headers") == "" {
t.Fatal("missing preflight support")
}
// Multipart parameters remain available after routing by URL path.
var body bytes.Buffer
form := multipart.NewWriter(&body)
form.WriteField("value", "upload-value")
form.WriteField("token", "android-form-token")
form.Close()
req := httptest.NewRequest(http.MethodPost, "/api/site_stats", &body)
req.Header.Set("Content-Type", form.FormDataContentType())
out = httptest.NewRecorder()
engine.ServeHTTP(out, req)
if out.Code != 200 || !strings.Contains(out.Body.String(), "android-form-token") || !strings.Contains(out.Body.String(), "upload-value") {
t.Fatalf("multipart response: %d %s", out.Code, out.Body.String())
}
// Admin login and authenticated requests share the existing Cookie session.
out = httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodPost, "/api/admin_login", nil))
cookies := out.Result().Cookies()
if len(cookies) == 0 || cookies[0].Path != "/" {
t.Fatal("admin Cookie must cover the new API paths")
}
req = httptest.NewRequest(http.MethodGet, "/api/admin_stats", nil)
req.AddCookie(cookies[0])
out = httptest.NewRecorder()
engine.ServeHTTP(out, req)
if out.Code != 200 {
t.Fatalf("authenticated admin: %d %s", out.Code, out.Body.String())
}
}
func TestCamelCaseContract(t *testing.T) {
router := NewRouter(&App{Cfg: &config.Config{DataDir: t.TempDir()}, Admin: NewAdminSessions()}, fstest.MapFS{})
router.Handle("site_info", func(c *Ctx) error {
return c.OK(M{"user_id": int64(9007199254740993), "cycle_length": c.InInt("cycle_length"), "profile_data": c.InMap("profile_data"), "page_size": c.Q("page_size"), "period_visible": c.Form("period_visible"), "avatar_path": "uploads/some_file.jpg"})
})
engine := router.Engine(false)
out := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/siteInfo?pageSize=20", strings.NewReader(`{"cycleLength":25,"profileData":{"startDate":"2026-10-05"}}`))
req.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(out, req)
body := out.Body.String()
for _, expected := range []string{`"userId":9007199254740993`, `"cycleLength":25`, `"profileData":{"startDate":"2026-10-05"}`, `"pageSize":"20"`, `uploads/some_file.jpg`} {
if !strings.Contains(body, expected) {
t.Fatalf("missing %s: %d %s", expected, out.Code, body)
}
}
if strings.Contains(body, `"cycle_length"`) {
t.Fatal("snake field leaked onto camel API")
}
// Legacy routes retain their existing JSON fields.
out = httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/backend/api/index.php?action=site_info", nil))
if !strings.Contains(out.Body.String(), `"user_id"`) {
t.Fatal("legacy contract changed")
}
var upload bytes.Buffer
form := multipart.NewWriter(&upload)
form.WriteField("periodVisible", "0")
form.Close()
req = httptest.NewRequest(http.MethodPost, "/api/siteInfo", &upload)
req.Header.Set("Content-Type", form.FormDataContentType())
out = httptest.NewRecorder()
engine.ServeHTTP(out, req)
if !strings.Contains(out.Body.String(), `"periodVisible":"0"`) {
t.Fatalf("camel multipart: %s", out.Body.String())
}
out = httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/api/adminStats", nil))
if out.Code != 401 {
t.Fatal("camel action bypassed admin authentication")
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package notify 对应 cron_notify.php:定时扫描 activity_log,补发未成功的订阅消息。
// PHP 版本靠外部 cron 触发;Go 版本在进程内按固定间隔运行。
package notify
import (
"log/slog"
"strings"
"time"
"past/internal/store"
"past/internal/wechat"
)
const (
maxBatch = 20
maxRetries = 3
scanMinutes = 30
)
var actionTypes = []string{
"login", "timeline_create", "album_create", "photo_upload", "diary_create", "diary_comment_add",
"song_add", "checkin_do", "period_add", "period_sex_add", "pet_adopt", "pet_daily", "pet_rename", "pet_all_done",
"couple_bind", "smoking_consume",
}
var actionLabels = map[string]string{
"login": "登录了", "timeline_create": "新增了记忆日", "album_create": "创建了新相册", "photo_upload": "上传了新照片",
"diary_create": "写了新的日记", "diary_comment_add": "评论了日记", "song_add": "添加了新歌曲", "checkin_do": "完成了打卡",
"period_add": "记录了姨妈期", "period_sex_add": "记录了亲密时光", "pet_adopt": "领养了新宠物", "pet_daily": "照顾了宠物",
"pet_rename": "给宠物改了名", "pet_all_done": "完成了所有宠物任务",
"couple_bind": "同意了你的邀请", "smoking_consume": "记录了抽烟",
}
var pageMap = map[string]string{
"login": "pages/index/index", "timeline_create": "pages/timeline/timeline", "album_create": "pages/albums/albums",
"photo_upload": "pages/albums/albums", "diary_create": "pages/diary/diary", "diary_comment_add": "pages/diary/diary",
"song_add": "pages/lovewords/lovewords", "checkin_do": "pages/punch/punch", "period_add": "pages/period/period",
"period_sex_add": "pages/period/period", "pet_adopt": "pages/pet/pet", "pet_daily": "pages/pet/pet",
"pet_rename": "pages/pet/pet", "pet_all_done": "pages/pet/pet",
"couple_bind": "pages/index/index", "smoking_consume": "pages/punch/punch",
}
func mbSub(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
// Run 按 interval 周期执行一次补发(阻塞,需 go 调用)
func Run(db *store.DB, wx *wechat.Client, interval time.Duration) {
for {
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("cron_notify panic", "err", r)
}
}()
Once(db, wx, false)
}()
time.Sleep(interval)
}
}
type Sender interface {
SendSubscribeMessage(toUserID int64, templateType string, data map[string]string, page string, activityID int64, userID int64) bool
}
// force 只放宽时间和次数限制,仍检查成功记录、关系和分享权限。
func Once(db *store.DB, wx Sender, force bool) {
ph := strings.TrimSuffix(strings.Repeat("?,", len(actionTypes)), ",")
args := make([]any, 0, len(actionTypes)+3)
for _, action := range actionTypes {
args = append(args, action)
}
query := `SELECT a.*, u.nickname, recipient.id AS to_user_id FROM activity_log a
JOIN users u ON u.id=a.user_id
JOIN couples cp ON cp.id=a.couple_id AND cp.status='active' AND u.couple_id=cp.id
JOIN users recipient ON recipient.id=CASE WHEN cp.user1_id=u.id THEN cp.user2_id ELSE cp.user1_id END AND recipient.couple_id=cp.id
JOIN subscribe_templates st ON st.user_id=recipient.id AND st.template_type='notify'
WHERE a.action_type IN (` + ph + `) AND u.id IN (cp.user1_id, cp.user2_id)
AND NOT EXISTS (SELECT 1 FROM notify_log nl WHERE nl.activity_id=a.id AND nl.to_user_id=recipient.id AND nl.status='success')
AND NOT EXISTS (SELECT 1 FROM subscribe_message_logs sl WHERE sl.activity_id=a.id AND sl.to_user_id=recipient.id AND sl.status='success')
AND (a.action_type NOT IN ('period_add','period_sex_add') OR COALESCE(u.period_visible,0)<>0)`
if !force {
query += ` AND a.created_at >= ? AND (SELECT COUNT(*) FROM subscribe_message_logs sl WHERE sl.activity_id=a.id AND sl.to_user_id=recipient.id) < ?`
args = append(args, time.Now().Add(-scanMinutes*time.Minute).Format("2006-01-02 15:04:05"), maxRetries)
}
query += " ORDER BY a.created_at DESC, a.id DESC LIMIT ?"
args = append(args, maxBatch)
activities, err := db.Query(query, args...)
if err != nil {
slog.Error("cron_notify 查询失败", "err", err)
return
}
processed, success, fail, skip := 0, 0, 0, 0
for _, a := range activities {
activityID, userID, actionType := a.Int("id"), a.Int("user_id"), a.Str("action_type")
nickname := a.Str("nickname")
if nickname == "" {
nickname = "你的TA"
}
partnerID := a.Int("to_user_id")
// 扫描后共享开关或关系可能改变;实际发送前再次检查。
allowed, err := db.QueryOne(`SELECT u.period_visible FROM users u JOIN couples cp ON cp.id=u.couple_id AND cp.status='active'
JOIN users recipient ON recipient.id=? AND recipient.couple_id=cp.id
WHERE u.id=? AND cp.id=? AND u.id IN (cp.user1_id,cp.user2_id) AND recipient.id IN (cp.user1_id,cp.user2_id) AND recipient.id<>u.id`, partnerID, userID, a.Int("couple_id"))
if err != nil || allowed == nil || ((actionType == "period_add" || actionType == "period_sex_add") && allowed.Int("period_visible") == 0) {
skip++
continue
}
if !force {
count, err := db.QueryOne("SELECT COUNT(*) as cnt FROM subscribe_message_logs WHERE activity_id=? AND to_user_id=?", activityID, partnerID)
if err != nil || count == nil || count.Int("cnt") >= maxRetries {
skip++
continue
}
}
// buildNotifyData
date := a.Str("created_at")
if len(date) >= 10 {
date = date[:10]
}
desc := a.Str("description")
label, ok := actionLabels[actionType]
if !ok {
label = "有了新操作"
}
page, ok := pageMap[actionType]
if !ok {
page = "pages/index/index"
}
thing1 := nickname + " " + label
thing2 := desc
if thing2 == "" {
thing2 = thing1
}
data := map[string]string{"thing1": mbSub(thing1, 20), "thing2": mbSub(thing2, 20), "date3": date, "thing4": "点击查看详情 💕"}
ok = wx.SendSubscribeMessage(partnerID, "notify", data, page, activityID, userID)
processed++
if ok {
success++
} else {
fail++
}
}
slog.Info("cron_notify 处理完成", "processed", processed, "success", success, "fail", fail, "skip", skip)
}
+68
View File
@@ -0,0 +1,68 @@
package notify
import (
"database/sql/driver"
"past/internal/store"
"past/internal/testdb"
"strings"
"testing"
)
type testSender struct {
calls int
to, from int64
}
func (s *testSender) SendSubscribeMessage(to int64, typ string, data map[string]string, page string, activity, from int64) bool {
s.calls++
s.to, s.from = to, from
return true
}
func TestRetrySelectionAndPermissionRecheck(t *testing.T) {
for _, tt := range []struct {
name string
visible int64
count int64
force bool
bound bool
want int
}{
{"retry failed event", 1, 1, false, true, 1},
{"retry cap", 1, 3, false, true, 0},
{"sharing switched off after scan", 0, 0, false, true, 0},
{"relationship changed after scan", 1, 0, false, false, 0},
{"force still checks sharing", 0, 0, true, true, 0},
} {
t.Run(tt.name, func(t *testing.T) {
steps := []testdb.Step{{Match: "SELECT a.*", Check: func(query string, _ []any) error {
for _, part := range []string{"nl.status='success'", "sl.status='success'", "cp.status='active'", "u.couple_id=cp.id", "COALESCE(u.period_visible,0)<>0", "JOIN subscribe_templates"} {
if !strings.Contains(query, part) {
t.Errorf("missing guard %s", part)
}
}
if !tt.force && (!strings.Contains(query, "COUNT(*)") || !strings.Contains(query, "a.created_at >= ?")) {
t.Error("missing bounded retry")
}
return nil
}, Columns: []string{"id", "couple_id", "user_id", "to_user_id", "action_type", "created_at"}, Rows: [][]driver.Value{{int64(9), int64(5), int64(1), int64(2), "period_add", "2026-10-11 12:00:00"}}}}
allowed := [][]driver.Value(nil)
if tt.bound {
allowed = [][]driver.Value{{tt.visible}}
}
steps = append(steps, testdb.Step{Match: "SELECT u.period_visible", Args: []any{int64(2), int64(1), int64(5)}, Columns: []string{"period_visible"}, Rows: allowed})
if tt.bound && tt.visible != 0 && !tt.force {
steps = append(steps, testdb.Step{Match: "SELECT COUNT(*) as cnt FROM subscribe_message_logs", Args: []any{int64(9), int64(2)}, Columns: []string{"cnt"}, Rows: [][]driver.Value{{tt.count}}})
}
db, _ := testdb.Open(t, steps)
sender := &testSender{}
Once(&store.DB{DB: db}, sender, tt.force)
if sender.calls != tt.want {
t.Fatalf("sent %d want %d", sender.calls, tt.want)
}
if sender.calls > 0 && (sender.to != 2 || sender.from != 1) {
t.Fatalf("message must go to partner: from=%d to=%d", sender.from, sender.to)
}
})
}
}
+375
View File
@@ -0,0 +1,375 @@
// Package store 封装 MySQL 访问。查询结果统一返回 Row(map),并按列类型转换成与 PHP PDO(mysqlnd)
// 一致的 JSON 表现:整数列 → 数字,DECIMAL/聚合 SUM/AVG → 字符串,日期时间 → 字符串,NULL → null。
package store
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
type DB struct {
*sql.DB
// NumericAsString 为 true 时,整数/浮点列按字符串返回,对齐 PHP < 8.1 的 PDO(模拟预处理)行为:
// 线上为 PHP 8.0,小程序一直接收的是 "id":"12" 这种格式。为 false 时对齐 PHP >= 8.1(原生类型)。
NumericAsString bool
UploadDir string // 用于解绑完成后的持久化附件清理队列
}
// Row 一行查询结果,键为列名(或别名)。
type Row map[string]any
func Open(dsn string) (*DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
return &DB{DB: db}, nil
}
// Querier 由 *DB 与 *Tx 共同实现,便于处理函数在事务内外复用。
type Querier interface {
Query(query string, args ...any) ([]Row, error)
QueryOne(query string, args ...any) (Row, error)
Exec(query string, args ...any) (sql.Result, error)
Insert(query string, args ...any) (int64, error)
}
type sqlRunner interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func (d *DB) Query(query string, args ...any) ([]Row, error) {
return queryRows(d.DB, d.NumericAsString, query, args...)
}
// QueryOne 返回第一行;无结果时返回 nil, nil(对应 PDO fetch() 返回 false)。
func (d *DB) QueryOne(query string, args ...any) (Row, error) {
return queryOne(d.DB, d.NumericAsString, query, args...)
}
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
return d.DB.ExecContext(context.Background(), query, args...)
}
// Insert 执行 INSERT 并返回自增 ID。
func (d *DB) Insert(query string, args ...any) (int64, error) {
res, err := d.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// Tx 事务
type Tx struct {
*sql.Tx
numStr bool
}
func (d *DB) Begin() (*Tx, error) {
tx, err := d.DB.Begin()
if err != nil {
return nil, err
}
return &Tx{tx, d.NumericAsString}, nil
}
func (t *Tx) Query(query string, args ...any) ([]Row, error) {
return queryRows(t.Tx, t.numStr, query, args...)
}
func (t *Tx) QueryOne(query string, args ...any) (Row, error) {
return queryOne(t.Tx, t.numStr, query, args...)
}
func (t *Tx) Exec(query string, args ...any) (sql.Result, error) {
return t.Tx.ExecContext(context.Background(), query, args...)
}
func (t *Tx) Insert(query string, args ...any) (int64, error) {
res, err := t.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func queryOne(r sqlRunner, numStr bool, query string, args ...any) (Row, error) {
rows, err := queryRows(r, numStr, query, args...)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
return rows[0], nil
}
func queryRows(r sqlRunner, numStr bool, query string, args ...any) ([]Row, error) {
rs, err := r.QueryContext(context.Background(), query, args...)
if err != nil {
return nil, err
}
defer rs.Close()
cols, err := rs.Columns()
if err != nil {
return nil, err
}
types, err := rs.ColumnTypes()
if err != nil {
return nil, err
}
kinds := make([]colKind, len(cols))
for i, t := range types {
kinds[i] = kindOf(t.DatabaseTypeName())
if numStr {
kinds[i] = kindString
}
}
out := make([]Row, 0, 8)
raw := make([]sql.RawBytes, len(cols))
ptrs := make([]any, len(cols))
for i := range raw {
ptrs[i] = &raw[i]
}
for rs.Next() {
if err := rs.Scan(ptrs...); err != nil {
return nil, err
}
row := make(Row, len(cols))
for i, c := range cols {
row[c] = convert(kinds[i], raw[i])
}
out = append(out, row)
}
return out, rs.Err()
}
type colKind int
const (
kindString colKind = iota
kindInt
kindFloat
)
func kindOf(dbType string) colKind {
t := strings.ToUpper(dbType)
t = strings.TrimPrefix(t, "UNSIGNED ")
switch t {
case "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT", "YEAR", "BIT":
return kindInt
case "FLOAT", "DOUBLE":
return kindFloat
default:
// DECIMAL / VARCHAR / TEXT / DATETIME / DATE / JSON ... 在 PDO 下均为字符串
return kindString
}
}
func convert(k colKind, b sql.RawBytes) any {
if b == nil {
return nil
}
switch k {
case kindInt:
if n, err := strconv.ParseInt(string(b), 10, 64); err == nil {
return n
}
return string(b)
case kindFloat:
if f, err := strconv.ParseFloat(string(b), 64); err == nil {
return f
}
return string(b)
default:
return string(b)
}
}
// ---- Row 取值辅助(对应 PHP 弱类型读取)----
func (r Row) Int(key string) int64 {
return ToInt(r[key])
}
func (r Row) Str(key string) string {
return ToStr(r[key])
}
func (r Row) IsNull(key string) bool {
v, ok := r[key]
return !ok || v == nil
}
// ToInt 模拟 PHP intval()
func ToInt(v any) int64 {
switch x := v.(type) {
case nil:
return 0
case int64:
return x
case int:
return int64(x)
case float64:
return int64(x)
case bool:
if x {
return 1
}
return 0
case string:
return atoiPrefix(x)
}
return 0
}
// ToFloat 模拟 PHP floatval()
func ToFloat(v any) float64 {
switch x := v.(type) {
case nil:
return 0
case int64:
return float64(x)
case int:
return float64(x)
case float64:
return x
case bool:
if x {
return 1
}
return 0
case string:
s := strings.TrimSpace(x)
// PHP floatval 取前缀数字
end := 0
for end < len(s) && (s[end] == '-' || s[end] == '+' || s[end] == '.' || (s[end] >= '0' && s[end] <= '9') || s[end] == 'e' || s[end] == 'E') {
end++
}
for end > 0 {
if f, err := strconv.ParseFloat(s[:end], 64); err == nil {
return f
}
end--
}
return 0
}
return 0
}
// ToStr 模拟 PHP 字符串化(数字转字符串,null 转空串)
func ToStr(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case int64:
return strconv.FormatInt(x, 10)
case int:
return strconv.Itoa(x)
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case bool:
if x {
return "1"
}
return ""
}
return fmt.Sprint(v)
}
// Truthy 模拟 PHP 的布尔判断(empty 的反义)
func Truthy(v any) bool {
switch x := v.(type) {
case nil:
return false
case string:
return x != "" && x != "0"
case int64:
return x != 0
case int:
return x != 0
case float64:
return x != 0
case bool:
return x
case []any:
return len(x) > 0
case map[string]any:
return len(x) > 0
}
return true
}
func atoiPrefix(s string) int64 {
s = strings.TrimSpace(s)
end := 0
if end < len(s) && (s[end] == '-' || s[end] == '+') {
end++
}
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
if end == 0 {
return 0
}
n, err := strconv.ParseInt(s[:end], 10, 64)
if err != nil {
// 可能是 "12.5" 这种,取整数部分
if f, ferr := strconv.ParseFloat(s, 64); ferr == nil {
return int64(f)
}
return 0
}
return n
}
// ExecNoFK 在一条独占连接上临时关闭外键检查执行 fn(用于 PHP 代码里向 pet_dynamics 写 user_id=0 的场景)。
func (d *DB) ExecNoFK(fn func(q Querier) error) error {
conn, err := d.DB.Conn(context.Background())
if err != nil {
return err
}
defer conn.Close()
if _, err := conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS = 0"); err != nil {
return err
}
defer conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS = 1")
return fn(&connQuerier{conn, d.NumericAsString})
}
type connQuerier struct {
*sql.Conn
numStr bool
}
func (c *connQuerier) Query(query string, args ...any) ([]Row, error) {
return queryRows(c.Conn, c.numStr, query, args...)
}
func (c *connQuerier) QueryOne(query string, args ...any) (Row, error) {
return queryOne(c.Conn, c.numStr, query, args...)
}
func (c *connQuerier) Exec(query string, args ...any) (sql.Result, error) {
return c.Conn.ExecContext(context.Background(), query, args...)
}
func (c *connQuerier) Insert(query string, args ...any) (int64, error) {
res, err := c.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
+139
View File
@@ -0,0 +1,139 @@
package store
import (
"crypto/sha256"
"fmt"
"log/slog"
"strings"
"time"
)
// AdvanceUnbindings runs on startup, in the worker, and before authenticated access.
// Expiry is decided with database time under the same row locks as cancellation.
func (db *DB) AdvanceUnbindings() error {
pending, err := db.Query("SELECT DISTINCT couple_id FROM unbind_requests WHERE status = 'cooling' AND cooling_until <= NOW()")
if err != nil {
return err
}
for _, row := range pending {
if err := db.finishUnbinding(row.Int("couple_id")); err != nil {
return err
}
}
return nil
}
func (db *DB) finishUnbinding(cid int64) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
cp, err := tx.QueryOne("SELECT id, couple_space_id FROM couples WHERE id = ? FOR UPDATE", cid)
if err != nil || cp == nil {
return err
}
req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id = ? AND status = 'cooling' AND cooling_until <= NOW() FOR UPDATE", cid)
if err != nil || req == nil {
return err
}
if err := queueUnboundFiles(tx, cid, cp.Str("couple_space_id")); err != nil {
return err
}
for _, stmt := range sharedDataDeleteStatements {
args := []any{cid}
if strings.Contains(stmt, "bgver_") {
args = append(args, cid)
}
if _, err = tx.Exec(stmt, args...); err != nil {
return err
}
}
for _, stmt := range []string{
"UPDATE users SET couple_id = NULL, updated_at = NOW() WHERE couple_id = ?",
"UPDATE couples SET status = 'unbound', data_deleted = 1 WHERE id = ?",
"UPDATE unbind_requests SET status = 'completed', completed_at = NOW() WHERE couple_id = ? AND status = 'cooling'",
} {
if _, err = tx.Exec(stmt, cid); err != nil {
return err
}
}
return tx.Commit()
}
func (db *DB) RunUnbindingWorker() {
run := func() {
if err := db.AdvanceUnbindings(); err != nil {
slog.Error("解绑到期处理失败", "err", err)
}
if err := db.cleanUnboundFiles(); err != nil {
slog.Error("解绑附件清理失败,将重试", "err", err)
}
}
run()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
run()
}
}
// MigrateSmokingRecords copies only legacy smoking inventory; shared journal tables stay unchanged.
func (db *DB) MigrateSmokingRecords() error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec("INSERT IGNORE INTO settings (`key`,`value`) VALUES ('smoking_records_v1','pending')"); err != nil {
return err
}
marker, err := tx.QueryOne("SELECT `value` FROM settings WHERE `key`='smoking_records_v1' FOR UPDATE")
if err != nil {
return err
}
if marker.Str("value") == "done" {
return tx.Commit()
}
previous, err := tx.QueryOne("SELECT `value` FROM settings WHERE `key`='account_records_v1'")
if err != nil {
return err
}
if previous != nil && previous.Str("value") == "done" {
if _, err = tx.Exec("UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"); err != nil {
return err
}
return tx.Commit()
}
accounts, err := tx.Query("SELECT user_id,SUM(stock) AS stock FROM smoking_accounts GROUP BY user_id")
if err != nil {
return err
}
for _, row := range accounts {
if _, err = tx.Exec("INSERT INTO smoking_wallets (user_id,stock) VALUES (?,?)", row["user_id"], row["stock"]); err != nil {
return err
}
}
logs, err := tx.Query("SELECT l.*,a.user_id FROM smoking_logs l JOIN smoking_accounts a ON a.id=l.account_id ORDER BY l.id ASC")
if err != nil {
return err
}
latestKeys := map[string]int64{}
for _, row := range logs {
latestKeys[fmt.Sprint(row.Int("user_id"), ":", row.Str("request_key"))] = row.Int("id")
}
balances := map[int64]int64{}
for _, row := range logs {
uid := row.Int("user_id")
balances[uid] += row.Int("delta")
key := row.Str("request_key")
if latestKeys[fmt.Sprint(uid, ":", key)] != row.Int("id") {
key = fmt.Sprintf("legacy-%x", sha256.Sum256([]byte(fmt.Sprint(row["id"], ":", key))))[:64]
}
if _, err = tx.Exec("INSERT INTO smoking_entries (id,user_id,kind,quantity,delta,balance,amount_cents,note,request_key,payload_hash,original_id,undone,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", row["id"], uid, row["kind"], row["quantity"], row["delta"], balances[uid], row["amount_cents"], row["note"], key, row["payload_hash"], row["original_id"], row["undone"], row["created_at"]); err != nil {
return err
}
}
if _, err = tx.Exec("UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"); err != nil {
return err
}
return tx.Commit()
}
@@ -0,0 +1,137 @@
package store
import (
"database/sql/driver"
"errors"
"fmt"
"past/internal/testdb"
"strings"
"testing"
)
func TestCoolingCompletionRechecksUnderLock(t *testing.T) {
for _, cancelled := range []bool{false, true} {
t.Run(map[bool]string{true: "cancelled before lock", false: "deadline reached"}[cancelled], func(t *testing.T) {
reqRows := [][]driver.Value{{int64(8)}}
if cancelled {
reqRows = nil
}
steps := []testdb.Step{
{Match: "SELECT DISTINCT couple_id", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(7)}}},
{Match: "FROM couples WHERE id = ? FOR UPDATE", Args: []any{int64(7)}, Columns: []string{"id"}, Rows: [][]driver.Value{{int64(7)}}},
{Match: "cooling_until <= NOW() FOR UPDATE", Columns: []string{"id"}, Rows: reqRows},
}
if !cancelled {
steps = append(steps, cleanupQuerySteps()...)
for _, stmt := range sharedDataDeleteStatements {
args := []any{int64(7)}
if strings.Contains(stmt, "bgver_") {
args = append(args, int64(7))
}
steps = append(steps, testdb.Step{Match: stmt, Args: args})
}
steps = append(steps, testdb.Step{Match: "UPDATE users SET couple_id = NULL"}, testdb.Step{Match: "data_deleted = 1"}, testdb.Step{Match: "status = 'completed'"})
}
sqlDB, s := testdb.Open(t, steps)
db := &DB{DB: sqlDB}
if err := db.AdvanceUnbindings(); err != nil {
t.Fatal(err)
}
if cancelled && s.Commits != 0 {
t.Fatal("cancelled relationship committed unbinding")
}
if !cancelled && s.Commits != 1 {
t.Fatal("completed unbinding did not commit")
}
})
}
}
func TestCoolingFailureRollsBackRelationship(t *testing.T) {
steps := []testdb.Step{
{Match: "SELECT DISTINCT", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(7)}}},
{Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(7)}}},
{Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(8)}}},
}
steps = append(steps, cleanupQuerySteps()...)
steps = append(steps, testdb.Step{Match: sharedDataDeleteStatements[0], Err: errors.New("database unavailable")})
sqlDB, s := testdb.Open(t, steps)
db := &DB{DB: sqlDB}
if err := db.AdvanceUnbindings(); err == nil {
t.Fatal("failure ignored")
}
if s.Commits != 0 || s.Rollbacks != 1 {
t.Fatalf("commits=%d rollbacks=%d", s.Commits, s.Rollbacks)
}
}
func TestSmokingMigrationDoesNotRepeat(t *testing.T) {
sqlDB, s := testdb.Open(t, []testdb.Step{{Match: "INSERT IGNORE INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}}})
if err := (&DB{DB: sqlDB}).MigrateSmokingRecords(); err != nil {
t.Fatal(err)
}
if s.Commits != 1 {
t.Fatal("migration marker not committed")
}
}
func TestSmokingMigrationPreservesMergedInventory(t *testing.T) {
checkEntry := func(id string, balance int64, key string) func(string, []any) error {
return func(_ string, a []any) error {
if a[0] != id || a[1] != int64(1) || a[5] != balance {
return fmt.Errorf("lost ledger id/owner/balance: %v", a)
}
if key != "" && a[8] != key {
return fmt.Errorf("latest retry key changed")
}
if key == "" && (a[8] == "same-key" || len(a[8].(string)) > 64) {
return fmt.Errorf("key collision not resolved")
}
return nil
}
}
columns := []string{"id", "user_id", "kind", "quantity", "delta", "amount_cents", "note", "request_key", "payload_hash", "original_id", "undone", "created_at"}
sqlDB, s := testdb.Open(t, []testdb.Step{
{Match: "INSERT IGNORE INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}},
{Match: "WHERE `key`='account_records_v1'", Columns: []string{"value"}},
{Match: "SUM(stock)", Columns: []string{"user_id", "stock"}, Rows: [][]driver.Value{{int64(1), int64(19)}}},
{Match: "INSERT INTO smoking_wallets", Args: []any{"1", "19"}},
{Match: "FROM smoking_logs", Columns: columns, Rows: [][]driver.Value{
{int64(10), int64(1), "purchase", int64(10), int64(10), int64(0), "", "same-key", "hash", nil, int64(0), "2026-10-01 00:00:00"},
{int64(11), int64(1), "purchase", int64(10), int64(10), int64(0), "", "same-key", "hash", nil, int64(0), "2026-10-02 00:00:00"},
{int64(12), int64(1), "consume", int64(1), int64(-1), int64(0), "", "consume-key", "hash", nil, int64(0), "2026-10-03 00:00:00"},
}},
{Match: "INSERT INTO smoking_entries", Check: checkEntry("10", 10, "")}, {Match: "INSERT INTO smoking_entries", Check: checkEntry("11", 20, "same-key")}, {Match: "INSERT INTO smoking_entries", Check: checkEntry("12", 19, "consume-key")},
{Match: "SET `value`='done'"},
})
if err := (&DB{DB: sqlDB}).MigrateSmokingRecords(); err != nil {
t.Fatal(err)
}
if s.Commits != 1 {
t.Fatal("migration was not atomic")
}
}
func TestSmokingMigrationRecognizesPreviousAccountMigration(t *testing.T) {
db, script := testdb.Open(t, []testdb.Step{
{Match: "INSERT IGNORE INTO settings"},
{Match: "smoking_records_v1' FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}},
{Match: "WHERE `key`='account_records_v1'", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}},
{Match: "UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"},
})
if err := (&DB{DB: db}).MigrateSmokingRecords(); err != nil {
t.Fatal(err)
}
if script.Commits != 1 {
t.Fatal("previous smoking migration was not acknowledged")
}
}
func cleanupQuerySteps() []testdb.Step {
return []testdb.Step{
{Match: "SELECT path AS media", Columns: []string{"media"}},
{Match: "SELECT cover AS media", Columns: []string{"media"}},
{Match: "SELECT images AS media FROM diaries", Columns: []string{"media"}},
{Match: "SELECT images AS media FROM timeline", Columns: []string{"media"}},
{Match: "SELECT bg_image AS media", Columns: []string{"media"}},
{Match: "SELECT `value` AS media", Columns: []string{"media"}},
}
}
+185
View File
@@ -0,0 +1,185 @@
package store
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strings"
"golang.org/x/crypto/bcrypt"
)
type addColumn struct {
Table, Column, DDL string
}
// InitSchema 对应 install.php 的 initDatabase():建表、补列、默认数据、历史数据修正。
// PHP 版本每次请求都执行;Go 版本只在启动时执行一次。对已迁移的线上库幂等。
func (db *DB) InitSchema() error {
// 会话级设置(SET NAMES / FOREIGN_KEY_CHECKS)必须落在同一条连接上,因此整个初始化独占一条连接
conn, err := db.DB.Conn(context.Background())
if err != nil {
return err
}
defer conn.Close()
d := &connRunner{conn}
if _, err := d.Exec("SET NAMES utf8mb4"); err != nil {
return err
}
// 与 PHP 一致:建表阶段关闭外键检查(表之间存在前向引用),结束后恢复
if _, err := d.Exec("SET FOREIGN_KEY_CHECKS = 0"); err != nil {
return err
}
defer d.Exec("SET FOREIGN_KEY_CHECKS = 1")
for _, stmt := range createTables {
if _, err := d.Exec(stmt); err != nil {
name := strings.Fields(stmt)[5]
return fmt.Errorf("建表 %s 失败: %w", name, err)
}
}
// 默认管理员(密码 admin123)
cnt, err := d.QueryOne("SELECT COUNT(*) as cnt FROM admins")
if err != nil {
return err
}
if cnt.Int("cnt") == 0 {
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
// 与 PHP password_hash 一致使用 $2y$ 前缀(算法相同)
phpHash := "$2y$" + strings.TrimPrefix(string(hash), "$2a$")
if _, err := d.Exec("INSERT INTO admins (username, password) VALUES (?, ?)", "admin", phpHash); err != nil {
return err
}
}
// 补列(含 users.phone 的特殊迁移逻辑)
for _, ac := range addColumns {
exists, err := d.columnExists(ac.Table, ac.Column)
if err != nil {
return err
}
if exists {
continue
}
if _, err := d.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", ac.Table, ac.Column, ac.DDL)); err != nil {
return fmt.Errorf("ALTER TABLE %s ADD COLUMN %s: %w", ac.Table, ac.Column, err)
}
if ac.Table == "users" && ac.Column == "phone" {
d.Exec("UPDATE users SET phone = username WHERE phone = '' OR phone IS NULL")
d.Exec("UPDATE users SET phone = CONCAT(id, '_old') WHERE phone = '' OR phone IS NULL")
d.Exec("CREATE UNIQUE INDEX idx_users_phone ON users(phone)")
}
}
// period 相关表 couple_id 允许 NULL:未绑定情侣时按 NULL 保存(对齐 cidOrNil 的设计)。
// 旧库建表时为 NOT NULL,这里做幂等的放宽约束迁移。
for _, t := range []string{"period_records", "period_daily_records", "period_sex_records"} {
if _, err := d.Exec("ALTER TABLE " + t + " MODIFY couple_id INT NULL"); err != nil {
slog.Debug("period 表 couple_id 修改为 NULL 失败", "table", t, "err", err)
}
}
// 确保 username 唯一索引(PHP 用 try/catch 忽略已存在)
if _, err := d.Exec("CREATE UNIQUE INDEX idx_users_username ON users(username)"); err != nil {
slog.Debug("idx_users_username 已存在或创建失败", "err", err)
}
// 修正已有照片 created_by 为 NULL 的记录(补齐为第一个用户)
np, err := d.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE created_by IS NULL")
if err != nil {
return err
}
if np.Int("cnt") > 0 {
first, _ := d.QueryOne("SELECT id FROM users LIMIT 1")
if first != nil {
d.Exec("UPDATE photos SET created_by = ? WHERE created_by IS NULL", first.Int("id"))
}
}
// 修正已有错误设置值
d.Exec("UPDATE settings SET `value` = ? WHERE `key` = ? AND `value` = ?", "我们past", "site_title", "叁叁肆计划巡演记录视频")
// 默认站点设置
defaults := [][2]string{
{"site_title", "我们past"},
{"site_subtitle", "记录每一个心动瞬间"},
{"theme_color", "#ff6b81"},
{"bg_style", "gradient"},
{"show_timeline", "1"},
{"show_album", "1"},
{"show_diary", "1"},
{"show_anniversary", "1"},
{"show_songs", "1"},
{"show_checkin", "1"},
}
for _, kv := range defaults {
if _, err := d.Exec("INSERT IGNORE INTO settings (`key`, `value`) VALUES (?, ?)", kv[0], kv[1]); err != nil {
return err
}
}
// 补全历史数据:last_login_at 为 NULL 的用户设为 created_at
d.Exec("UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL")
// 爱爱记录 measure 字段 '无性行为' → '无措施'
d.Exec("UPDATE period_sex_records SET measure = '无措施' WHERE measure = '无性行为'")
// index.php 的 diary_list/diary_detail 引用了 diary_likes / diary_favorites,但 install.php 从未建表
// (线上库应已存在)。这里仅在缺失时补建,避免全新环境 500;已存在则不做任何改动。
for _, t := range []string{"diary_likes", "diary_favorites"} {
if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS ` + t + ` (
id INT AUTO_INCREMENT PRIMARY KEY,
diary_id INT NOT NULL,
user_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_diary_user (diary_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); err != nil {
return err
}
}
if _, err := d.Exec("UPDATE unbind_requests r JOIN couples c ON c.id=r.couple_id SET r.status=CASE WHEN c.status='unbound' THEN 'completed' ELSE 'cooling' END,r.cooling_until=CASE WHEN c.status='unbound' THEN NULL ELSE DATE_ADD(NOW(),INTERVAL 72 HOUR) END WHERE r.status='agreed'"); err != nil {
return err
}
idx, err := d.Query("SHOW INDEX FROM unbind_requests WHERE Key_name='unbind_deadline'")
if err != nil {
return err
}
if len(idx) == 0 {
if _, err := d.Exec("CREATE INDEX unbind_deadline ON unbind_requests(status,cooling_until)"); err != nil {
return err
}
}
for _, stmt := range []string{
"CREATE TABLE IF NOT EXISTS smoking_wallets (user_id INT PRIMARY KEY, stock INT NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS smoking_entries (id INT AUTO_INCREMENT PRIMARY KEY,user_id INT NOT NULL,kind VARCHAR(20) NOT NULL,quantity INT NOT NULL,delta INT NOT NULL,balance INT NOT NULL,amount_cents INT NOT NULL DEFAULT 0,note VARCHAR(200) NOT NULL DEFAULT '',request_key VARCHAR(64) NOT NULL,payload_hash CHAR(64) NOT NULL,original_id INT DEFAULT NULL,undone TINYINT NOT NULL DEFAULT 0,created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,UNIQUE KEY smoking_personal_request(user_id,request_key),KEY smoking_personal_recent(user_id,created_at),FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
} {
if _, err := d.Exec(stmt); err != nil {
return err
}
}
return nil
}
// connRunner 让单条 *sql.Conn 具备 Query/QueryOne/Exec 能力
type connRunner struct{ *sql.Conn }
func (c *connRunner) Query(query string, args ...any) ([]Row, error) {
return queryRows(c.Conn, false, query, args...)
}
func (c *connRunner) QueryOne(query string, args ...any) (Row, error) {
return queryOne(c.Conn, false, query, args...)
}
func (c *connRunner) Exec(query string, args ...any) (sql.Result, error) {
return c.Conn.ExecContext(context.Background(), query, args...)
}
func (d *connRunner) columnExists(table, column string) (bool, error) {
rows, err := d.Query("SHOW COLUMNS FROM "+table+" LIKE ?", column)
if err != nil {
return false, err
}
return len(rows) > 0, nil
}
+490
View File
@@ -0,0 +1,490 @@
package store
// 本文件由 backend/install.php 的 SQL 语句自动提取生成,表结构与线上 PHP 版本保持一致。
// 请勿手工改动字段定义;如需新增字段,追加到 addColumns 中。
// createTables 对应 install.php 中全部 CREATE TABLE IF NOT EXISTS(按原顺序)。
var createTables = []string{
`CREATE TABLE IF NOT EXISTS unbinding_file_cleanup (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
path VARCHAR(500) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(20) UNIQUE,
openid VARCHAR(100) UNIQUE,
phone VARCHAR(50) DEFAULT '',
username VARCHAR(100) DEFAULT '',
password VARCHAR(255) DEFAULT '',
nickname VARCHAR(100) DEFAULT '',
avatar VARCHAR(500) DEFAULT '',
gender VARCHAR(10) DEFAULT '',
couple_id INT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS couples (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_space_id VARCHAR(50) UNIQUE,
user1_id INT NOT NULL,
user2_id INT NOT NULL,
love_start_date VARCHAR(20) DEFAULT '',
love_story TEXT,
status VARCHAR(20) DEFAULT 'active',
data_deleted TINYINT DEFAULT 0,
period_visible TINYINT DEFAULT 0 COMMENT '男方是否可见女生日历 0-不可见 1-可见',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user1_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (user2_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS invitations (
id INT AUTO_INCREMENT PRIMARY KEY,
from_user_id INT NOT NULL,
to_user_id INT DEFAULT NULL,
to_phone VARCHAR(50) DEFAULT '',
status VARCHAR(20) DEFAULT 'pending',
message TEXT,
invite_code VARCHAR(100) DEFAULT '',
type VARCHAR(20) DEFAULT 'phone',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
responded_at DATETIME,
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS unbind_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
requester_id INT NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
responded_at DATETIME DEFAULT NULL,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS invite_links (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
code VARCHAR(100) NOT NULL UNIQUE,
used INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS timeline (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
event_date VARCHAR(20) NOT NULL,
location VARCHAR(200) DEFAULT '',
images TEXT,
icon VARCHAR(20) DEFAULT '❤️',
sort_order INT DEFAULT 0,
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS albums (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
cover VARCHAR(500) DEFAULT '',
sort_order INT DEFAULT 0,
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
location VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS photos (
id INT AUTO_INCREMENT PRIMARY KEY,
album_id INT NOT NULL,
path VARCHAR(500) NOT NULL,
description TEXT,
taken_at VARCHAR(50) DEFAULT '',
location VARCHAR(200) DEFAULT '',
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
activity VARCHAR(200) DEFAULT '',
batch_id VARCHAR(50) DEFAULT '',
sort_order INT DEFAULT 0,
created_by INT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS diaries (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
mood VARCHAR(20) DEFAULT '😊',
weather VARCHAR(20) DEFAULT '☀️',
diary_date VARCHAR(20) NOT NULL,
images TEXT,
is_anniversary INT DEFAULT 0,
region VARCHAR(100) DEFAULT '' COMMENT '发布地区',
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
location VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS songs (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
song_name VARCHAR(255) NOT NULL,
artist VARCHAR(255) NOT NULL,
note VARCHAR(120) DEFAULT '',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS checkins (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
icon VARCHAR(20) DEFAULT '✅',
type VARCHAR(10) DEFAULT 'dual',
frequency VARCHAR(10) DEFAULT 'daily',
is_completed INT DEFAULT 0,
completed_by_me INT DEFAULT 0,
completed_by_partner INT DEFAULT 0,
my_checkin_time DATETIME,
partner_checkin_time DATETIME,
my_streak INT DEFAULT 0,
partner_streak INT DEFAULT 0,
my_photo VARCHAR(500) DEFAULT '',
partner_photo VARCHAR(500) DEFAULT '',
my_note VARCHAR(200) DEFAULT '',
partner_note VARCHAR(200) DEFAULT '',
completed_at DATETIME,
sort_order INT DEFAULT 0,
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS checkin_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
checkin_id INT NOT NULL,
couple_id INT NOT NULL,
user_id INT NOT NULL,
photo VARCHAR(500) DEFAULT '',
note VARCHAR(200) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS smoking_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
user_id INT NOT NULL,
stock INT NOT NULL DEFAULT 0,
checkin_id INT DEFAULT NULL,
UNIQUE KEY smoking_owner (couple_id, user_id),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS smoking_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
account_id INT NOT NULL,
kind VARCHAR(20) NOT NULL,
quantity INT NOT NULL,
delta INT NOT NULL,
balance INT NOT NULL,
amount_cents INT NOT NULL DEFAULT 0,
note VARCHAR(200) NOT NULL DEFAULT '',
request_key VARCHAR(64) NOT NULL,
payload_hash CHAR(64) NOT NULL,
checkin_log_id INT DEFAULT NULL,
original_id INT DEFAULT NULL,
undone TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY smoking_request (account_id, request_key),
KEY smoking_recent (account_id, created_at),
FOREIGN KEY (account_id) REFERENCES smoking_accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_records (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL,
user_id INT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
cycle_length INT DEFAULT 28,
period_length INT DEFAULT 5,
symptoms VARCHAR(500) DEFAULT '',
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_daily_records (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL,
user_id INT NOT NULL,
record_date DATE NOT NULL,
period ENUM('0','1','2','3') DEFAULT '0' COMMENT '0-无 1-少量 2-中量 3-大量',
sex ENUM('0','1') DEFAULT '0' COMMENT '0-无 1-爱爱',
symptoms VARCHAR(200) DEFAULT '' COMMENT '症状,逗号分隔',
mood VARCHAR(100) DEFAULT '' COMMENT '心情',
discharge VARCHAR(100) DEFAULT '' COMMENT '白带',
temperature DECIMAL(4,1) DEFAULT NULL COMMENT '体温',
weight DECIMAL(5,1) DEFAULT NULL COMMENT '体重',
note TEXT COMMENT '备注',
period_detail TEXT COMMENT '经期详情JSON(量/颜色/疼痛)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_date_couple (record_date, couple_id),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_sex_records (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL,
user_id INT NOT NULL,
record_date DATE NOT NULL,
record_time TIME DEFAULT NULL COMMENT '记录时间,精确到分钟',
measure VARCHAR(100) DEFAULT '' COMMENT '措施类型',
measure_custom VARCHAR(200) DEFAULT '' COMMENT '其他措施自定义内容',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_date_couple (record_date, couple_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS settings (
` + "`" + `key` + "`" + ` VARCHAR(255) PRIMARY KEY,
` + "`" + `value` + "`" + ` TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS diary_comments (
id INT AUTO_INCREMENT PRIMARY KEY,
diary_id INT NOT NULL,
couple_id INT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL,
parent_id INT DEFAULT 0 COMMENT '回复的评论ID,0为顶级评论',
reply_to_user_id INT DEFAULT NULL COMMENT '被回复的用户ID',
reply_to_username VARCHAR(100) DEFAULT '' COMMENT '被回复的用户昵称',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (diary_id) REFERENCES diaries(id) ON DELETE CASCADE,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_diary (diary_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS subscribe_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
template_id VARCHAR(100) NOT NULL,
template_type VARCHAR(50) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_template (user_id, template_type),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS activity_log (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
user_id INT NOT NULL,
action_type VARCHAR(50) NOT NULL,
description VARCHAR(500) NOT NULL,
detail TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_couple_time (couple_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS user_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(128) NOT NULL UNIQUE,
user_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS subscribe_message_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
activity_id INT NOT NULL DEFAULT 0,
couple_id INT DEFAULT NULL,
user_id INT NOT NULL DEFAULT 0,
to_user_id INT NOT NULL,
to_openid VARCHAR(128) NOT NULL DEFAULT '',
action_type VARCHAR(50) NOT NULL DEFAULT '',
message_title VARCHAR(255) NOT NULL DEFAULT '',
message_content TEXT,
template_type VARCHAR(50) NOT NULL,
template_id VARCHAR(128) NOT NULL DEFAULT '',
page VARCHAR(255) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL,
error_code INT DEFAULT NULL,
http_status INT DEFAULT NULL,
error_msg TEXT,
attempts INT NOT NULL DEFAULT 0,
duration_ms INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME DEFAULT NULL,
INDEX idx_status_time (status, created_at),
INDEX idx_recipient_time (to_user_id, created_at),
INDEX idx_openid (to_openid),
INDEX idx_event_time (action_type, created_at),
INDEX idx_activity (activity_id),
INDEX idx_couple (couple_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS notify_log (
id INT AUTO_INCREMENT PRIMARY KEY,
activity_id INT NOT NULL,
couple_id INT NOT NULL,
user_id INT NOT NULL,
action_type VARCHAR(50) NOT NULL,
template_type VARCHAR(50) NOT NULL,
to_user_id INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT '',
error_msg TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME DEFAULT NULL,
UNIQUE KEY uk_activity (activity_id, to_user_id),
INDEX idx_status_time (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pets (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL DEFAULT '小狗狗',
breed VARCHAR(20) NOT NULL DEFAULT 'golden' COMMENT 'golden/husky/corgi/shiba',
gender TINYINT NOT NULL DEFAULT 1 COMMENT '1-男 2-女',
stage INT NOT NULL DEFAULT 1 COMMENT '1幼犬 2少年 3青年 4成年 5终极',
level INT NOT NULL DEFAULT 1,
exp INT NOT NULL DEFAULT 0,
happiness INT NOT NULL DEFAULT 100,
hunger INT NOT NULL DEFAULT 100,
energy INT NOT NULL DEFAULT 100,
cleanliness INT NOT NULL DEFAULT 100,
adopted_at DATE NOT NULL,
created_by INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_dailies (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
date DATE NOT NULL,
feed_u1 TINYINT DEFAULT 0,
feed_u2 TINYINT DEFAULT 0,
water_u1 TINYINT DEFAULT 0,
water_u2 TINYINT DEFAULT 0,
walk_u1 TINYINT DEFAULT 0,
walk_u2 TINYINT DEFAULT 0,
bath_u1 TINYINT DEFAULT 0,
bath_u2 TINYINT DEFAULT 0,
sleep_u1 TINYINT DEFAULT 0,
sleep_u2 TINYINT DEFAULT 0,
play_u1 TINYINT DEFAULT 0,
play_u2 TINYINT DEFAULT 0,
all_done TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_couple_date (couple_id, date),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
achievement_key VARCHAR(50) NOT NULL,
unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_couple_ach (couple_id, achievement_key),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_items (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
item_key VARCHAR(50) NOT NULL,
item_type VARCHAR(20) NOT NULL COMMENT 'accessory/collar/scarf/background/consumable',
quantity INT DEFAULT 1,
equipped TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_couple (couple_id),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_dynamics (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
user_id INT NOT NULL,
action VARCHAR(30) NOT NULL,
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_couple_time (couple_id, created_at DESC),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
// addColumns 对应 install.php 中的 ALTER TABLE ADD COLUMN 迁移(原代码用 try/catch 或 SHOW COLUMNS 判断,这里统一按列是否存在判断)。
var addColumns = []addColumn{
{"unbind_requests", "cancelled_at", "DATETIME DEFAULT NULL"},
{"unbind_requests", "cancelled_by", "INT DEFAULT NULL"},
{"unbind_requests", "cooling_until", "DATETIME DEFAULT NULL"},
{"unbind_requests", "completed_at", "DATETIME DEFAULT NULL"},
{"checkins", "type", "VARCHAR(10) DEFAULT 'dual'"},
{"checkins", "frequency", "VARCHAR(10) DEFAULT 'daily'"},
{"checkins", "completed_by_me", "INT DEFAULT 0"},
{"checkins", "completed_by_partner", "INT DEFAULT 0"},
{"checkins", "my_checkin_time", "DATETIME"},
{"checkins", "partner_checkin_time", "DATETIME"},
{"checkins", "my_streak", "INT DEFAULT 0"},
{"checkins", "partner_streak", "INT DEFAULT 0"},
{"checkins", "my_photo", "VARCHAR(500) DEFAULT ''"},
{"checkins", "partner_photo", "VARCHAR(500) DEFAULT ''"},
{"checkins", "my_note", "VARCHAR(200) DEFAULT ''"},
{"checkins", "partner_note", "VARCHAR(200) DEFAULT ''"},
{"period_daily_records", "period_detail", "TEXT COMMENT '经期详情JSON(量/颜色/疼痛)'"},
{"users", "openid", "VARCHAR(100) UNIQUE"},
{"users", "phone", "VARCHAR(50) DEFAULT ''"},
{"users", "gender", "VARCHAR(10) DEFAULT ''"},
{"users", "period_visible", "TINYINT NOT NULL DEFAULT 1 COMMENT '姨姨:是否对伴侣可见'"},
{"users", "age", "INT DEFAULT 0"},
{"users", "birthday", "VARCHAR(20) DEFAULT ''"},
{"users", "last_login_at", "DATETIME DEFAULT NULL"},
{"users", "region", "VARCHAR(100) DEFAULT '' COMMENT '地区,如云南楚雄'"},
{"users", "location", "VARCHAR(200) DEFAULT '' COMMENT '模糊定位,如纬度,经度'"},
{"users", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度'"},
{"users", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度'"},
{"users", "last_login_location", "VARCHAR(200) DEFAULT '' COMMENT '最近一次登录地址'"},
{"users", "ip_geo_date", "DATE DEFAULT NULL COMMENT '最后一次 IP 定位日期(每日限流)'"},
{"users", "last_ip", "VARCHAR(64) DEFAULT '' COMMENT '最后一次 IP 定位的客户端 IP 地址'"},
{"invitations", "to_phone", "VARCHAR(50) DEFAULT ''"},
{"invitations", "invite_code", "VARCHAR(100) DEFAULT ''"},
{"invitations", "type", "VARCHAR(20) DEFAULT 'phone'"},
{"couples", "couple_space_id", "VARCHAR(50) UNIQUE"},
{"couples", "period_source_user_id", "INT DEFAULT NULL COMMENT '绑定前姨姨记录选定的来源用户'"},
{"couples", "data_deleted", "TINYINT DEFAULT 0"},
{"photos", "taken_at", "VARCHAR(50) DEFAULT ''"},
{"photos", "location", "VARCHAR(200) DEFAULT ''"},
{"photos", "batch_id", "VARCHAR(50) DEFAULT ''"},
{"photos", "activity", "VARCHAR(200) DEFAULT ''"},
{"photos", "created_by", "INT DEFAULT NULL"},
{"diaries", "region", "VARCHAR(100) DEFAULT '' COMMENT '发布地区' AFTER is_anniversary"},
{"diaries", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER region"},
{"diaries", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
{"diaries", "location", "VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称' AFTER lng"},
{"albums", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER sort_order"},
{"albums", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
{"albums", "location", "VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称' AFTER lng"},
{"photos", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER location"},
{"photos", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
{"timeline", "location", "VARCHAR(200) DEFAULT '' AFTER event_date"},
{"timeline", "repeat_interval", "INT DEFAULT 0 AFTER location"},
{"timeline", "repeat_unit", "VARCHAR(10) DEFAULT '' AFTER repeat_interval"},
{"timeline", "bg_image", "VARCHAR(500) DEFAULT '' AFTER repeat_unit"},
{"couples", "data_deleted", "TINYINT(1) DEFAULT 0 COMMENT '0=数据保留,1=数据已删除'"},
}
+149
View File
@@ -0,0 +1,149 @@
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// 仅删除当前情侣空间的数据,不按用户 ID 删除其他空间或个人账本。
var sharedDataDeleteStatements = []string{
"DELETE l FROM smoking_logs l JOIN smoking_accounts a ON a.id=l.account_id WHERE a.couple_id = ?",
"DELETE FROM smoking_accounts WHERE couple_id = ?",
"DELETE FROM diary_comments WHERE couple_id = ?",
"DELETE FROM checkin_logs WHERE couple_id = ?",
"DELETE p FROM photos p JOIN albums a ON a.id=p.album_id WHERE a.couple_id = ?",
"DELETE FROM albums WHERE couple_id = ?",
"DELETE FROM diaries WHERE couple_id = ?",
"DELETE FROM timeline WHERE couple_id = ?",
"DELETE FROM songs WHERE couple_id = ?",
"DELETE FROM checkins WHERE couple_id = ?",
"DELETE FROM period_records WHERE couple_id = ?",
"DELETE FROM period_daily_records WHERE couple_id = ?",
"DELETE FROM period_sex_records WHERE couple_id = ?",
"DELETE FROM pet_dailies WHERE couple_id = ?",
"DELETE FROM pet_achievements WHERE couple_id = ?",
"DELETE FROM pet_items WHERE couple_id = ?",
"DELETE FROM pet_dynamics WHERE couple_id = ?",
"DELETE FROM pets WHERE couple_id = ?",
"DELETE FROM activity_log WHERE couple_id = ?",
"DELETE FROM notify_log WHERE couple_id = ?",
"DELETE FROM subscribe_message_logs WHERE couple_id = ?",
"DELETE FROM settings WHERE `key` IN (CONCAT('bg_', ?), CONCAT('bgver_', ?))",
}
func queueUnboundFiles(tx *Tx, cid int64, space string) error {
queries := []string{
"SELECT path AS media FROM photos WHERE album_id IN (SELECT id FROM albums WHERE couple_id = ?)",
"SELECT cover AS media FROM albums WHERE couple_id = ?",
"SELECT images AS media FROM diaries WHERE couple_id = ?",
"SELECT images AS media FROM timeline WHERE couple_id = ?",
"SELECT bg_image AS media FROM timeline WHERE couple_id = ?",
"SELECT `value` AS media FROM settings WHERE `key` = CONCAT('bg_', ?)",
}
paths := map[string]bool{}
for _, query := range queries {
records, err := tx.Query(query, cid)
if err != nil {
return err
}
for _, record := range records {
value := record.Str("media")
var images []string
if json.Unmarshal([]byte(value), &images) == nil {
for _, image := range images {
if p := cleanupPath(image); p != "" {
paths[p] = true
}
}
} else if p := cleanupPath(value); p != "" {
paths[p] = true
}
}
}
// 清除该空间目录内已失去引用的附件,保留用户头像和个人目录。
if space == fmt.Sprintf("CP%06d", cid) {
paths["couples/"+space+"/"] = true
}
for path := range paths {
if _, err := tx.Exec("INSERT INTO unbinding_file_cleanup (couple_id,path) VALUES (?,?)", cid, path); err != nil {
return err
}
}
return nil
}
func cleanupPath(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "/backend/")
value = strings.TrimPrefix(value, "backend/")
value = strings.TrimPrefix(value, "/")
if !strings.HasPrefix(value, "uploads/") {
return ""
}
value = strings.SplitN(value, "?", 2)[0]
value = strings.TrimPrefix(value, "uploads/")
if strings.ContainsAny(value, "\\:\x00") {
return ""
}
for _, segment := range strings.Split(value, "/") {
if segment == "." || segment == ".." || segment == "" {
return ""
}
}
if strings.HasPrefix(value, "avatars/") {
return ""
}
return value
}
// 文件删除在数据库提交之后执行;失败保留任务,下次启动或轮询重试。
func (db *DB) cleanUnboundFiles() error {
if db.UploadDir == "" {
return nil
}
tasks, err := db.Query("SELECT id,couple_id,path FROM unbinding_file_cleanup ORDER BY id LIMIT 500")
if err != nil {
return err
}
for _, task := range tasks {
path := task.Str("path")
directory := path == "couples/"+fmt.Sprintf("CP%06d", task.Int("couple_id"))+"/"
if !directory && cleanupPath("uploads/"+path) != path {
return fmt.Errorf("invalid unbinding cleanup path %q", path)
}
target := filepath.Join(db.UploadDir, filepath.FromSlash(path))
// 不跟随目录链接,避免删除上传根目录之外的数据。
parent := filepath.Dir(target)
if directory {
parent = filepath.Dir(strings.TrimSuffix(target, string(filepath.Separator)))
}
root, rootErr := filepath.EvalSymlinks(db.UploadDir)
actual, parentErr := filepath.EvalSymlinks(parent)
if rootErr != nil {
return rootErr
}
if parentErr == nil {
rel, err := filepath.Rel(root, actual)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("cleanup outside upload directory")
}
} else if !os.IsNotExist(parentErr) {
return parentErr
}
if directory {
err = os.RemoveAll(target)
} else {
err = os.Remove(target)
}
if err != nil && !os.IsNotExist(err) {
return err
}
if _, err = db.Exec("DELETE FROM unbinding_file_cleanup WHERE id = ?", task.Int("id")); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,61 @@
package store
import (
"database/sql/driver"
"os"
"past/internal/testdb"
"path/filepath"
"testing"
)
func TestCleanupPathsProtectPersonalAndOutsideFiles(t *testing.T) {
for _, input := range []string{"uploads/../secret", "uploads/users/1/../../secret", "uploads/avatars/520001/avatar.jpg", "https://example.com/image.jpg", "uploads/C:\\secret", "uploads//secret"} {
if cleanupPath(input) != "" {
t.Fatalf("unsafe or personal path accepted: %q", input)
}
}
if got := cleanupPath("/backend/uploads/users/1/diary/a.jpg?v=1"); got != "users/1/diary/a.jpg" {
t.Fatal(got)
}
}
func TestUnbindingFilesDeleteOnlyQueuedMediaAndRetryMissingFiles(t *testing.T) {
root := t.TempDir()
shared := filepath.Join(root, "users/1/diary/a.jpg")
avatar := filepath.Join(root, "avatars/520001/avatar.jpg")
for _, p := range []string{shared, avatar} {
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("image"), 0600); err != nil {
t.Fatal(err)
}
}
db, _ := testdb.Open(t, []testdb.Step{
{Match: "FROM unbinding_file_cleanup", Columns: []string{"id", "couple_id", "path"}, Rows: [][]driver.Value{{int64(1), int64(7), "users/1/diary/a.jpg"}, {int64(2), int64(7), "users/1/diary/already-gone.jpg"}}},
{Match: "DELETE FROM unbinding_file_cleanup", Args: []any{int64(1)}},
{Match: "DELETE FROM unbinding_file_cleanup", Args: []any{int64(2)}},
})
if err := (&DB{DB: db, UploadDir: root}).cleanUnboundFiles(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(shared); !os.IsNotExist(err) {
t.Fatal("shared attachment remains")
}
if _, err := os.Stat(avatar); err != nil {
t.Fatal("personal avatar deleted")
}
}
func TestCleanupFailureRetainsTaskForRetry(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "users/1/diary/folder")
if err := os.MkdirAll(target, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "child"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
db, _ := testdb.Open(t, []testdb.Step{{Match: "FROM unbinding_file_cleanup", Columns: []string{"id", "couple_id", "path"}, Rows: [][]driver.Value{{int64(1), int64(7), "users/1/diary/folder"}}}})
if err := (&DB{DB: db, UploadDir: root}).cleanUnboundFiles(); err == nil {
t.Fatal("failure ignored, task must remain queued")
}
}
+144
View File
@@ -0,0 +1,144 @@
// Package testdb supplies a deterministic database/sql driver for transaction failure tests.
package testdb
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
)
type Step struct {
Check func(string, []any) error
Match string
Args []any
Columns []string
Rows [][]driver.Value
Err error
ID int64
}
type Script struct {
Steps []Step
Index int
Commits, Rollbacks int
mu sync.Mutex
}
var scripts sync.Map
var sequence atomic.Int64
func init() { sql.Register("past_test_script", scriptDriver{}) }
func Open(t *testing.T, steps []Step) (*sql.DB, *Script) {
t.Helper()
name := fmt.Sprint(sequence.Add(1))
s := &Script{Steps: steps}
scripts.Store(name, s)
db, err := sql.Open("past_test_script", name)
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() {
db.Close()
scripts.Delete(name)
if s.Index != len(s.Steps) {
t.Errorf("executed %d of %d SQL steps", s.Index, len(s.Steps))
}
})
return db, s
}
type scriptDriver struct{}
func (scriptDriver) Open(name string) (driver.Conn, error) {
s, _ := scripts.Load(name)
return &conn{s.(*Script)}, nil
}
type conn struct{ s *Script }
func (c *conn) Prepare(string) (driver.Stmt, error) {
return nil, fmt.Errorf("unexpected prepared statement")
}
func (c *conn) Close() error { return nil }
func (c *conn) Begin() (driver.Tx, error) { return &tx{c.s}, nil }
func (c *conn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { return c.Begin() }
func (c *conn) step(query string, args []driver.NamedValue) (Step, error) {
c.s.mu.Lock()
defer c.s.mu.Unlock()
if c.s.Index >= len(c.s.Steps) {
return Step{}, fmt.Errorf("unexpected SQL %s", query)
}
s := c.s.Steps[c.s.Index]
c.s.Index++
if !strings.Contains(query, s.Match) {
return s, fmt.Errorf("expected SQL containing %q got %s", s.Match, query)
}
if s.Args != nil {
values := make([]any, len(args))
for i, a := range args {
values[i] = a.Value
}
if !reflect.DeepEqual(values, s.Args) {
return s, fmt.Errorf("SQL args %v wanted %v", values, s.Args)
}
}
if s.Check != nil {
values := make([]any, len(args))
for i, a := range args {
values[i] = a.Value
}
if err := s.Check(query, values); err != nil {
return s, err
}
}
return s, s.Err
}
func (c *conn) QueryContext(_ context.Context, q string, a []driver.NamedValue) (driver.Rows, error) {
s, err := c.step(q, a)
if err != nil {
return nil, err
}
return &rows{columns: s.Columns, data: s.Rows}, nil
}
func (c *conn) ExecContext(_ context.Context, q string, a []driver.NamedValue) (driver.Result, error) {
s, err := c.step(q, a)
if err != nil {
return nil, err
}
return result{s.ID}, nil
}
type tx struct{ s *Script }
func (t *tx) Commit() error { t.s.Commits++; return nil }
func (t *tx) Rollback() error { t.s.Rollbacks++; return nil }
type result struct{ id int64 }
func (r result) LastInsertId() (int64, error) { return r.id, nil }
func (r result) RowsAffected() (int64, error) { return 1, nil }
type rows struct {
columns []string
data [][]driver.Value
index int
}
func (r *rows) Columns() []string { return r.columns }
func (r *rows) Close() error { return nil }
func (r *rows) Next(dest []driver.Value) error {
if r.index >= len(r.data) {
return io.EOF
}
copy(dest, r.data[r.index])
r.index++
return nil
}
+194
View File
@@ -0,0 +1,194 @@
// Package upload 对应 config.php 中的 handleUpload() 与路径辅助函数。
package upload
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// 上传错误码(对应 config.php UPLOAD_ERR_* 常量)
const (
ErrNoFileReceived = 1001
ErrFileFieldMissing = 1002
ErrHandleFailed = 1003
ErrRawBodySave = 1004
ErrAlbumNotFound = 1005
ErrNotLoggedIn = 1006
ErrNotBound = 1007
ErrMethodNotAllowed = 1008
ErrPartnerInvalid = 1009
ErrFileTooLarge = 1010
)
var allowedExts = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true}
// ImageChecker 图片内容安全检测(由 wechat 包实现)
type ImageChecker interface {
CheckImage(filePath, openid string) (bool, string)
}
type Uploader struct {
// Dir 上传根目录(UPLOAD_DIR)
Dir string
MaxSize int64
Checker ImageChecker
}
// Result 对应 handleUpload 返回的 ['path' => ...] 或 ['error' => ...]
type Result struct {
Path string // 相对路径,如 uploads/avatars/520001/20260101120000_ab12cd34.jpg
Error string
}
// AvatarPath 对应 avatarPath($displayId) → avatars/{displayId}
func AvatarPath(userID string) string { return "avatars/" + userID }
// CouplePath 对应 couplePath($spaceId, $type)
func CouplePath(spaceID, typ string) string {
if typ != "" {
return "couples/" + spaceID + "/" + typ
}
return "couples/" + spaceID
}
// UserFilePath 对应 userFilePath($userId, $type) → users/{userId}/{type}
func UserFilePath(userID int64, typ string) string {
p := fmt.Sprintf("users/%d", userID)
if typ != "" {
p += "/" + typ
}
return p
}
// NewFilename 对应 date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . ext
func NewFilename(ext string) string {
b := make([]byte, 4)
rand.Read(b)
return time.Now().Format("20060102150405") + "_" + hex.EncodeToString(b) + "." + ext
}
// Ext 取小写扩展名(不含点)
func Ext(name string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
}
// Handle 对应 handleUpload($file, $subDir, $prefixPath)
func (u *Uploader) Handle(fh *multipart.FileHeader, subDir, prefixPath string) Result {
if fh == nil {
return Result{Error: "未接收到文件"}
}
f, err := fh.Open()
if err != nil {
return Result{Error: "未接收到文件"}
}
defer f.Close()
return u.HandleReader(f, fh.Filename, fh.Size, subDir, prefixPath)
}
// HandleReader 与 Handle 相同的校验流程(大小/扩展名/MIME),输入为任意可 seek 的数据源
func (u *Uploader) HandleReader(f io.ReadSeeker, filename string, size int64, subDir, prefixPath string) Result {
if size > u.MaxSize {
return Result{Error: fmt.Sprintf("文件超过大小限制(%dMB)", u.MaxSize/1024/1024)}
}
ext := Ext(filename)
if !allowedExts[ext] {
return Result{Error: "不支持的文件类型(." + ext + "),仅支持: jpg,jpeg,png,gif,webp"}
}
// MIME 检测(对应 finfo):仅当既不是 image/* 也不是 octet-stream 时拒绝
head := make([]byte, 512)
n, _ := io.ReadFull(f, head)
// finfo 只返回主类型(不带 "; charset=...")
mime := http.DetectContentType(head[:n])
if i := strings.Index(mime, ";"); i >= 0 {
mime = strings.TrimSpace(mime[:i])
}
if !strings.HasPrefix(mime, "image/") && mime != "application/octet-stream" {
return Result{Error: "文件MIME类型不匹配: " + mime}
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return Result{Error: "文件读取失败"}
}
return u.save(f, "", ext, subDir, prefixPath)
}
// HandleBytes 对 HandleReader 的便捷封装(原始请求体 → 伪装成 $_FILES 走完整校验)
func (u *Uploader) HandleBytes(data []byte, filename, subDir, prefixPath string) Result {
return u.HandleReader(bytes.NewReader(data), filename, int64(len(data)), subDir, prefixPath)
}
// SaveRaw 保存原始二进制数据(photo_upload / set_bg_image 的 RAW BODY 兜底路径),不做扩展名/MIME 校验。
// namePrefix 为文件名前缀(如 "bg_")。
func (u *Uploader) SaveRaw(data []byte, namePrefix, ext, subDir, prefixPath string) Result {
return u.save(bytes.NewReader(data), namePrefix, ext, subDir, prefixPath)
}
func (u *Uploader) save(src io.Reader, namePrefix, ext, subDir, prefixPath string) Result {
rel := "uploads/"
dir := u.Dir
if prefixPath != "" {
rel += prefixPath + "/"
dir = filepath.Join(dir, filepath.FromSlash(prefixPath))
}
if subDir != "" {
rel += subDir + "/"
dir = filepath.Join(dir, filepath.FromSlash(subDir))
}
if err := os.MkdirAll(dir, 0755); err != nil {
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
}
name := namePrefix + NewFilename(ext)
dest := filepath.Join(dir, name)
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
}
if _, err := io.Copy(out, src); err != nil {
out.Close()
os.Remove(dest)
return Result{Error: "文件保存失败。系统错误: " + err.Error()}
}
out.Close()
slog.Info("upload saved", "path", rel+name)
if u.Checker != nil {
if pass, msg := u.Checker.CheckImage(dest, ""); !pass {
os.Remove(dest)
return Result{Error: msg}
}
}
return Result{Path: rel + name}
}
// Remove 删除 uploads 下的文件(path 为 DB 中存的相对路径 uploads/...);忽略错误。
func (u *Uploader) Remove(relPath string) {
if relPath == "" || strings.Contains(relPath, "..") {
return
}
p := strings.TrimPrefix(relPath, "uploads/")
if p == relPath {
return
}
os.Remove(filepath.Join(u.Dir, filepath.FromSlash(p)))
}
// Abs 返回相对路径对应的绝对路径
func (u *Uploader) Abs(relPath string) string {
p := strings.TrimPrefix(relPath, "uploads/")
return filepath.Join(u.Dir, filepath.FromSlash(p))
}
// Exists 文件是否存在
func (u *Uploader) Exists(relPath string) bool {
st, err := os.Stat(u.Abs(relPath))
return err == nil && !st.IsDir()
}
+145
View File
@@ -0,0 +1,145 @@
package wechat
import (
"database/sql/driver"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"past/internal/store"
"past/internal/testdb"
"path/filepath"
"strings"
"testing"
)
type subscribeTransport func(*http.Request) (*http.Response, error)
func (f subscribeTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestSubscribeRecordsEveryOutcome(t *testing.T) {
for _, tt := range []struct {
name, openid, template, response, reason string
subscribed, success bool
code any
requests int
}{
{name: "success", openid: "recipient-openid", template: "template-id", subscribed: true, response: `{"errcode":0}`, success: true, code: int64(0), requests: 1},
{name: "quota exhausted", openid: "recipient-openid", template: "template-id", subscribed: true, response: `{"errcode":43101,"errmsg":"user refuse"}`, reason: "43101", code: int64(43101), requests: 1},
{name: "malformed response", openid: "recipient-openid", template: "template-id", subscribed: true, response: `not-json`, reason: "无效", requests: 1},
{name: "invalid errcode", openid: "recipient-openid", template: "template-id", subscribed: true, response: `{"errcode":"invalid"}`, reason: "无效", requests: 1},
{name: "missing errcode", openid: "recipient-openid", template: "template-id", subscribed: true, response: `{}`, reason: "errcode", requests: 1},
{name: "no authorization", openid: "recipient-openid", template: "template-id", reason: "未订阅"},
{name: "no openid", template: "template-id", reason: "openid"},
{name: "no template", openid: "recipient-openid", reason: "模板未配置"},
} {
t.Run(tt.name, func(t *testing.T) {
steps := []testdb.Step{{Match: "SELECT status FROM notify_log", Columns: []string{"status"}}, {Match: "SELECT openid FROM users", Args: []any{int64(2)}, Columns: []string{"openid"}, Rows: [][]driver.Value{{tt.openid}}}}
if tt.openid != "" && tt.template != "" {
auth := [][]driver.Value(nil)
if tt.subscribed {
auth = [][]driver.Value{{int64(1)}}
}
steps = append(steps, testdb.Step{Match: "FROM subscribe_templates", Args: []any{int64(2), "notify"}, Columns: []string{"id"}, Rows: auth})
}
if tt.code == int64(43101) {
steps = append(steps, testdb.Step{Match: "DELETE FROM subscribe_templates"})
}
meta := []testdb.Step{{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(5)}}}, {Match: "SELECT action_type FROM activity_log", Columns: []string{"action_type"}, Rows: [][]driver.Value{{"diary_create"}}}}
steps = append(steps, meta...)
steps = append(steps, testdb.Step{Match: "INSERT INTO subscribe_message_logs", Check: func(q string, args []any) error {
if args[4] != tt.openid || args[5] != "diary_create" || args[6] != "new diary" {
t.Errorf("wrong recipient/event: %v", args)
return nil
}
if args[7] != strings.Repeat("爱", 20) {
t.Errorf("log must record normalized content: %v", args[7])
}
status := "failed"
if tt.success {
status = "success"
}
if args[11] != status || args[12] != tt.code || args[15] != int64(tt.requests) {
t.Errorf("wrong result: %v", args)
return nil
}
if !strings.Contains(fmt.Sprint(args[14]), tt.reason) {
t.Errorf("missing failure reason: %v", args[14])
return nil
}
if tt.success && args[17] == nil {
t.Error("missing sent_at")
return nil
}
if !tt.success && args[17] != nil {
t.Error("failed message has sent_at")
return nil
}
return nil
}})
steps = append(steps, meta...)
steps = append(steps, testdb.Step{Match: "SELECT COUNT(*) as cnt FROM notify_log", Columns: []string{"cnt"}, Rows: [][]driver.Value{{int64(0)}}}, testdb.Step{Match: "INSERT INTO notify_log"})
db, _ := testdb.Open(t, steps)
client := New("app", "secret", tt.template, t.TempDir(), &store.DB{DB: db})
os.MkdirAll(filepath.Dir(client.cacheFile()), 0755)
os.WriteFile(client.cacheFile(), []byte("cached-token"), 0600)
calls := 0
client.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) {
calls++
var payload map[string]any
json.NewDecoder(r.Body).Decode(&payload)
if payload["touser"] != tt.openid {
t.Fatal("wrong WeChat recipient")
}
if payload["data"].(map[string]any)["thing2"].(map[string]any)["value"] != strings.Repeat("爱", 20) {
t.Fatal("overlong content sent to WeChat")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(tt.response))}, nil
})
success := client.SendSubscribeMessage(2, "notify", map[string]string{"thing1": "new diary", "thing2": strings.Repeat("爱", 50)}, "", 10, 1)
if success != tt.success || calls != tt.requests {
t.Fatalf("success=%v requests=%d", success, calls)
}
})
}
}
func TestSubscribeWithoutActivityStillRecords(t *testing.T) {
db, _ := testdb.Open(t, []testdb.Step{
{Match: "SELECT openid FROM users", Columns: []string{"openid"}, Rows: [][]driver.Value{{"recipient"}}},
{Match: "SELECT id FROM subscribe_templates", Columns: []string{"id"}},
{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{nil}}},
{Match: "INSERT INTO subscribe_message_logs", Check: func(_ string, args []any) error {
if args[0] != int64(0) || args[4] != "recipient" || args[11] != "failed" {
t.Error("missing zero-activity log")
return nil
}
return nil
}},
})
client := New("app", "secret", "template", t.TempDir(), &store.DB{DB: db})
if client.SendSubscribeMessage(2, "notify", nil, "", 0, 0) {
t.Fatal("unsubscribed send succeeded")
}
}
func TestSuccessfulEventIsNotSentAgain(t *testing.T) {
db, _ := testdb.Open(t, []testdb.Step{{Match: "SELECT status FROM notify_log", Columns: []string{"status"}, Rows: [][]driver.Value{{"success"}}}})
client := New("app", "secret", "template", t.TempDir(), &store.DB{DB: db})
client.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) { t.Fatal("duplicate sent to WeChat"); return nil, nil })
if !client.SendSubscribeMessage(2, "notify", nil, "", 10, 1) {
t.Fatal("completed message must be treated as success")
}
}
func TestThingNormalizationDoesNotMutateInput(t *testing.T) {
input := map[string]string{"thing1": strings.Repeat("爱💕", 30), "thing2": " ", "thing4": "行一\n行二", "date3": "2026-10-11"}
output := normalizeSubscribeData(input)
if len([]rune(output["thing1"])) != 20 || output["thing2"] == "" || output["thing4"] != "行一 行二" || output["date3"] != input["date3"] {
t.Fatalf("wrong normalized fields: %v", output)
}
if output["thing1"] == input["thing1"] || input["thing2"] != " " {
t.Fatal("input was mutated")
}
}
+408
View File
@@ -0,0 +1,408 @@
// Package wechat 对应 config.php 中的微信相关函数:access_token 缓存、内容安全检测、订阅消息、jscode2session。
package wechat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"past/internal/store"
)
type Client struct {
AppID string
Secret string
NotifyTemplateID string
DataDir string // 用于 access_token.cache
DB *store.DB
http *http.Client
mu sync.Mutex
sendLocks [64]sync.Mutex
}
func New(appID, secret, notifyTemplateID, dataDir string, db *store.DB) *Client {
return &Client{
AppID: appID, Secret: secret, NotifyTemplateID: notifyTemplateID, DataDir: dataDir, DB: db,
http: &http.Client{Timeout: 10 * time.Second},
}
}
// Templates 对应 WECHAT_SUBSCRIBE_TEMPLATES
func (c *Client) Templates() map[string]string {
return map[string]string{"notify": c.NotifyTemplateID}
}
func (c *Client) cacheFile() string { return filepath.Join(c.DataDir, "data", "access_token.cache") }
// AccessToken 获取 access_token;文件缓存 115 分钟(按修改时间判断),与 PHP 一致。
func (c *Client) AccessToken() string {
c.mu.Lock()
defer c.mu.Unlock()
cf := c.cacheFile()
if st, err := os.Stat(cf); err == nil && time.Since(st.ModTime()) < 6900*time.Second {
if b, err := os.ReadFile(cf); err == nil && len(bytes.TrimSpace(b)) > 0 {
return string(bytes.TrimSpace(b))
}
}
u := "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + url.QueryEscape(c.AppID) + "&secret=" + url.QueryEscape(c.Secret)
resp, err := c.http.Get(u)
if err != nil {
return ""
}
defer resp.Body.Close()
var data struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.AccessToken == "" {
return ""
}
os.MkdirAll(filepath.Dir(cf), 0755)
os.WriteFile(cf, []byte(data.AccessToken), 0644)
return data.AccessToken
}
func (c *Client) clearTokenCache() {
c.mu.Lock()
os.Remove(c.cacheFile())
c.mu.Unlock()
}
// postJSON 对应 wechatPost:非 200 返回 nil
func (c *Client) postJSON(u string, body []byte, timeout time.Duration) map[string]any {
cl := &http.Client{Timeout: timeout}
resp, err := cl.Post(u, "application/json", bytes.NewReader(body))
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil
}
return out
}
func errcode(m map[string]any) int64 {
if m == nil {
return -1
}
if v, ok := m["errcode"]; ok {
return store.ToInt(v)
}
return -1
}
// CheckText 文本内容安全检测(msg_sec_check)。返回 (pass, errmsg)。
// 与 PHP 一致:token 获取失败 / 网络异常 / 非 87014 错误均放行。
func (c *Client) CheckText(text, openid string) (bool, string) {
if bytes.TrimSpace([]byte(text)) == nil || len(bytes.TrimSpace([]byte(text))) == 0 {
return true, ""
}
tok := c.AccessToken()
if tok == "" {
return true, ""
}
body, _ := marshalUnescaped(map[string]any{
"version": 2, "openid": openid, "scene": 2, "content": text,
})
res := c.postJSON("https://api.weixin.qq.com/wxa/msg_sec_check?access_token="+tok, body, 10*time.Second)
if res == nil {
return true, ""
}
if errcode(res) == 87014 {
return false, "所发布内容含违规信息"
}
return true, ""
}
// CheckImage 图片内容安全检测(media_check_async)。PHP 实现只是异步提交,结果永远放行。
func (c *Client) CheckImage(filePath, openid string) (bool, string) {
st, err := os.Stat(filePath)
if err != nil || st.IsDir() {
return true, ""
}
tok := c.AccessToken()
if tok == "" {
return true, ""
}
f, err := os.Open(filePath)
if err != nil {
return true, ""
}
defer f.Close()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("media", filepath.Base(filePath))
io.Copy(fw, f)
mw.WriteField("scene", "2")
mw.WriteField("openid", openid)
mw.Close()
req, _ := http.NewRequest("POST", "https://api.weixin.qq.com/wxa/media_check_async?access_token="+tok, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := c.http.Do(req)
if err == nil {
resp.Body.Close()
}
return true, ""
}
// Jscode2Session 通过 code 换取 openid。返回 (openid, errmsg, ok);ok=false 且 errmsg 为空表示服务不可用。
func (c *Client) Jscode2Session(code string) (openid string, wxErr string, available bool) {
u := "https://api.weixin.qq.com/sns/jscode2session?appid=" + c.AppID + "&secret=" + c.Secret + "&js_code=" + url.QueryEscape(code) + "&grant_type=authorization_code"
resp, err := c.http.Get(u)
if err != nil {
return "", "", false
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if len(b) == 0 {
return "", "", false
}
var data map[string]any
if err := json.Unmarshal(b, &data); err != nil {
return "", "", true
}
if ec, ok := data["errcode"]; ok && store.ToInt(ec) != 0 {
msg := store.ToStr(data["errmsg"])
if msg == "" {
msg = "未知错误"
}
return "", msg, true
}
return store.ToStr(data["openid"]), "", true
}
// SendSubscribeMessage 对应 sendSubscribeMessage()。
func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data map[string]string, page string, activityID int64, userID int64) bool {
// 同一事件的即时发送和补发串行,并在发送前重新检查成功记录。
if activityID != 0 {
lock := &c.sendLocks[uint64(activityID+toUserID)%uint64(len(c.sendLocks))]
lock.Lock()
defer lock.Unlock()
previous, err := c.DB.QueryOne("SELECT status FROM notify_log WHERE activity_id = ? AND to_user_id = ? AND status='success' UNION ALL SELECT status FROM subscribe_message_logs WHERE activity_id = ? AND to_user_id = ? AND status='success' LIMIT 1", activityID, toUserID, activityID, toUserID)
if err != nil {
slog.Error("读取通知去重状态失败", "err", err)
return false
}
if previous != nil && previous.Str("status") == "success" {
return true
}
}
data = normalizeSubscribeData(data)
started := time.Now()
templateID, known := c.Templates()[templateType]
status, lastError, openid := "failed", "", ""
attempts := 0
var errorCode, httpStatus any
if page == "" {
page = "pages/index/index"
}
defer func() {
c.logSubscribeAttempt(activityID, toUserID, userID, templateType, templateID, openid, page, data, status, lastError, errorCode, httpStatus, attempts, started)
if activityID != 0 {
c.LogNotifyResult(activityID, toUserID, userID, templateType, status, lastError)
}
}()
user, err := c.DB.QueryOne("SELECT openid FROM users WHERE id = ?", toUserID)
if err != nil {
lastError = "读取收件人失败"
return false
}
if user != nil {
openid = user.Str("openid")
}
if !known || templateID == "" {
lastError = "订阅消息模板未配置"
return false
}
if openid == "" {
lastError = "用户无openid"
return false
}
sub, err := c.DB.QueryOne("SELECT id FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType)
if err != nil {
lastError = "读取订阅授权失败"
return false
}
if sub == nil {
lastError = "用户未订阅"
return false
}
msgData := map[string]any{}
for k, v := range data {
msgData[k] = map[string]any{"value": v}
}
post, err := marshalUnescaped(map[string]any{
"touser": openid, "template_id": templateID, "page": page,
"data": msgData, "miniprogram_state": "formal", "lang": "zh_CN",
})
if err != nil {
lastError = "消息内容编码失败"
return false
}
for attempt := 1; attempt <= 2; attempt++ {
tok := c.AccessToken()
if tok == "" {
lastError = "获取access_token失败"
if attempt < 2 {
c.clearTokenCache()
}
continue
}
attempts++
cl := &http.Client{Timeout: 5 * time.Second, Transport: c.http.Transport}
resp, err := cl.Post("https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token="+tok, "application/json", bytes.NewReader(post))
if err != nil {
// 不记录含 access_token 的 URL;底层错误足以定位网络故障。
lastError = "微信发送请求失败"
if uerr, ok := err.(*url.Error); ok {
lastError += ": " + uerr.Err.Error()
}
if attempt < 2 {
c.clearTokenCache()
time.Sleep(200 * time.Millisecond)
}
continue
}
httpStatus = resp.StatusCode
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
lastError = fmt.Sprintf("微信接口HTTP %d", resp.StatusCode)
if attempt < 2 {
c.clearTokenCache()
time.Sleep(200 * time.Millisecond)
}
continue
}
var result map[string]any
decodeErr := json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
_, validCode := result["errcode"].(float64)
if decodeErr != nil || !validCode {
lastError = "微信返回内容无效或缺少errcode"
break
}
ec := errcode(result)
errorCode = ec
if ec == 0 {
status, lastError = "success", ""
return true
}
lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec)
if (ec == 40001 || ec == 40014 || ec == 42001) && attempt < 2 {
c.clearTokenCache()
continue
}
if ec == 40003 || ec == 43101 {
c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType)
}
break
}
return false
}
// 全部入口统一限制 thing 字段长度,日志保存实际下发内容。
func normalizeSubscribeData(data map[string]string) map[string]string {
out := make(map[string]string, len(data))
for key, value := range data {
if strings.HasPrefix(key, "thing") {
value = strings.Join(strings.Fields(value), " ")
if value == "" {
value = "有新的记录"
}
runes := []rune(value)
if len(runes) > 20 {
value = string(runes[:20])
}
}
out[key] = value
}
return out
}
// 每次逻辑发送追加一条记录;notify_log 继续负责事件去重。
func (c *Client) logSubscribeAttempt(activityID, toUserID, userID int64, templateType, templateID, openid, page string, data map[string]string, status, errorMsg string, errorCode, httpStatus any, attempts int, started time.Time) {
lookup := userID
if lookup == 0 {
lookup = toUserID
}
var coupleID any
if user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", lookup); user != nil {
coupleID = user["couple_id"]
}
action := ""
if activityID != 0 {
if activity, _ := c.DB.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID); activity != nil {
action = activity.Str("action_type")
}
}
if action == "" {
action = "notify"
}
var sentAt any
if status == "success" {
sentAt = time.Now().Format("2006-01-02 15:04:05")
}
_, err := c.DB.Exec(`INSERT INTO subscribe_message_logs (activity_id, couple_id, user_id, to_user_id, to_openid, action_type, message_title, message_content, template_type, template_id, page, status, error_code, http_status, error_msg, attempts, duration_ms, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
activityID, coupleID, userID, toUserID, openid, action, data["thing1"], data["thing2"], templateType, templateID, page, status, errorCode, httpStatus, errorMsg, attempts, time.Since(started).Milliseconds(), sentAt)
if err != nil {
slog.Error("订阅消息发送日志写入失败", "activity_id", activityID, "to_user_id", toUserID, "err", err)
}
}
// LogNotifyResult 对应 logNotifyResult()
func (c *Client) LogNotifyResult(activityID, toUserID, userID int64, templateType, status, errorMsg string) {
db := c.DB
now := time.Now().Format("2006-01-02 15:04:05")
lookup := userID
if lookup == 0 {
lookup = toUserID
}
u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", lookup)
var coupleID any
if u != nil {
coupleID = u["couple_id"]
}
actionType := ""
if activityID != 0 {
a, _ := db.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID)
if a != nil {
actionType = a.Str("action_type")
}
}
cnt, _ := db.QueryOne("SELECT COUNT(*) as cnt FROM notify_log WHERE activity_id = ? AND to_user_id = ?", activityID, toUserID)
var err error
if cnt != nil && cnt.Int("cnt") > 0 {
_, err = db.Exec("UPDATE notify_log SET status = ?, error_msg = ?, sent_at = ? WHERE activity_id = ? AND to_user_id = ?", status, errorMsg, now, activityID, toUserID)
} else {
_, err = db.Exec("INSERT INTO notify_log (activity_id, couple_id, user_id, action_type, template_type, to_user_id, status, error_msg, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
activityID, coupleID, userID, actionType, templateType, toUserID, status, errorMsg, now)
}
if err != nil {
slog.Error("logNotifyResult error", "err", err)
}
}
func marshalUnescaped(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我们past · 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7630
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.3.0",
"lucide-react": "^1.46.0",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"shadcn": "^4.21.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@types/node": "^24.13.5",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="52">💗</text>
</svg>

After

Width:  |  Height:  |  Size: 191 B

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+56
View File
@@ -0,0 +1,56 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { api } from "@/lib/api"
import Layout from "@/components/Layout"
import Login from "@/pages/Login"
import Dashboard from "@/pages/Dashboard"
import UsersPage from "@/pages/Users"
import CouplesPage from "@/pages/Couples"
import ContentsPage from "@/pages/Contents"
import BackgroundsPage from "@/pages/Backgrounds"
import UnbindPage from "@/pages/Unbind"
import PetsPage from "@/pages/Pets"
import SubscribeMessagesPage from "@/pages/SubscribeMessages"
import SettingsPage from "@/pages/Settings"
// 登录态:admin_check_auth(Cookie 会话)
export function useAuth() {
return useQuery({
queryKey: ["auth"],
// admin_check_auth 与 PHP 一致:未登录时直接 401,这里把它当作 logged_in=false
queryFn: () => api<{ logged_in: boolean; username?: string }>("adminCheckAuth", null, "GET").catch(() => ({ logged_in: false })),
staleTime: 60_000,
})
}
export default function App() {
const auth = useAuth()
const loc = useLocation()
if (auth.isPending) {
return <div className="flex min-h-screen items-center justify-center text-muted-foreground">加载中…</div>
}
const loggedIn = !!auth.data?.logged_in
return (
<Routes>
<Route path="/login" element={loggedIn ? <Navigate to="/" replace /> : <Login />} />
{loggedIn ? (
<Route element={<Layout username={localStorage.getItem("admin_username") || "admin"} />}>
<Route index element={<Dashboard />} />
<Route path="users" element={<UsersPage />} />
<Route path="couples" element={<CouplesPage />} />
<Route path="contents" element={<ContentsPage />} />
<Route path="backgrounds" element={<BackgroundsPage />} />
<Route path="unbind" element={<UnbindPage />} />
<Route path="pets" element={<PetsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="subscribe-messages" element={<SubscribeMessagesPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
) : (
<Route path="*" element={<Navigate to="/login" replace state={{ from: loc.pathname }} />} />
)}
</Routes>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,176 @@
import { useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { api, fmtTime, parseImages, daysSince, BREED, BREED_EMOJI, STAGE, STAGE_COLOR } from "@/lib/api"
import { Avatar, Thumb, ThumbGallery } from "@/components/ImagePreview"
import { Empty, Loading, ErrorBox, RecordItem } from "@/components/common"
import { DeleteContentDialog, type DeleteContentTarget } from "@/components/UserDialogs"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
export function CoupleDetailDialog({ coupleId, onClose }: { coupleId: number | null; onClose: () => void }) {
const qc = useQueryClient()
const q = useQuery({
queryKey: ["admin_couple_detail", coupleId],
queryFn: () => api("adminCoupleDetail", { couple_id: coupleId }, "GET"),
enabled: !!coupleId,
})
const [del, setDel] = useState<DeleteContentTarget | null>(null)
const d = q.data
const users: any[] = d?.users || []
const delBtn = (type: string, id: number, label: string) => (
<Button size="xs" variant="destructive" className="ml-auto shrink-0" onClick={(e) => { e.stopPropagation(); setDel({ type, id, coupleId: coupleId!, label }) }}>🗑️ 删除</Button>
)
return (
<Dialog open={!!coupleId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>💕 情侣详情</DialogTitle>
<DialogDescription asChild>
<div className="flex flex-wrap items-center gap-1.5">
<span>情侣成员:</span>
{users.length ? users.map((u) => (
<span key={u.id} className="inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-pink-400 to-violet-400 px-2.5 py-0.5 text-xs text-white">
<Avatar path={u.avatar} name={u.nickname || u.username} size={18} />
{u.nickname || u.username || "?"} (ID:{u.user_id || "-"})
</span>
)) : <span className="text-muted-foreground">已解除绑定</span>}
</div>
</DialogDescription>
</DialogHeader>
{q.isPending ? <Loading /> : q.isError ? <ErrorBox error={q.error} /> : (
<Tabs defaultValue="timeline">
<TabsList className="flex w-full flex-wrap justify-start">
<TabsTrigger value="timeline">📅 记忆日</TabsTrigger>
<TabsTrigger value="diary">📝 日记</TabsTrigger>
<TabsTrigger value="album">📷 相册</TabsTrigger>
<TabsTrigger value="photos">🖼️ 照片</TabsTrigger>
<TabsTrigger value="songs">🎵 歌曲</TabsTrigger>
<TabsTrigger value="checkin">✅ 打卡</TabsTrigger>
<TabsTrigger value="activity">📋 操作记录</TabsTrigger>
<TabsTrigger value="pet">🐾 宠物</TabsTrigger>
</TabsList>
<TabsContent value="timeline">
{!d.timeline?.length ? <Empty icon="📅" text="暂无" /> : d.timeline.map((t: any) => (
<RecordItem key={t.id} icon="📅" right={<><span className="text-xs text-muted-foreground">{t.event_date}</span>{delBtn("timeline", t.id, t.title)}</>}
footer={<div className="flex flex-wrap gap-1.5"><Thumb path={t.bg_image} /><ThumbGallery paths={parseImages(t.images)} /></div>}>
<span className="font-medium">{t.title}</span>{t.location && <span className="text-muted-foreground"> 📍{t.location}</span>}
{t.content && <div className="text-xs text-muted-foreground">{t.content}</div>}
</RecordItem>
))}
</TabsContent>
<TabsContent value="diary">
{!d.diaries?.length ? <Empty icon="📝" text="暂无" /> : d.diaries.map((x: any) => (
<RecordItem key={x.id} icon="📝" right={<><span className="text-xs text-muted-foreground">{x.diary_date}</span>{delBtn("diary", x.id, x.title || "日记")}</>} footer={<ThumbGallery paths={parseImages(x.images)} />}>
{x.title || "一篇日记"}
</RecordItem>
))}
</TabsContent>
<TabsContent value="album">
{!d.albums?.length ? <Empty icon="📷" text="暂无" /> : d.albums.map((a: any) => (
<RecordItem key={a.id} icon="📷" right={delBtn("album", a.id, a.title)}>
<div className="flex items-center gap-2"><Thumb path={a.cover} className="size-10" /><div>{a.title}<div className="text-xs text-muted-foreground">{a.description}</div></div></div>
</RecordItem>
))}
</TabsContent>
<TabsContent value="photos">
{!d.photos?.length ? <Empty icon="🖼️" text="暂无照片" /> : (
<>
<div className="flex flex-wrap gap-1.5 py-2">{d.photos.map((p: any) => <Thumb key={p.id} path={p.path} title={p.description} className="size-20" />)}</div>
<div className="text-center text-xs text-muted-foreground">共 {d.photos.length} 张照片</div>
</>
)}
</TabsContent>
<TabsContent value="songs">
{!d.songs?.length ? <Empty icon="🎵" text="暂无" /> : d.songs.map((s: any) => (
<RecordItem key={s.id} icon="🎵" right={delBtn("song", s.id, s.song_name)}>
{s.song_name} — {s.artist}{s.note && <div className="text-xs text-muted-foreground">📝 {s.note}</div>}
</RecordItem>
))}
</TabsContent>
<TabsContent value="checkin">
{!d.checkins?.length ? <Empty icon="✅" text="暂无" /> : d.checkins.map((c: any) => (
<RecordItem key={c.id} icon="✅" right={delBtn("checkin", c.id, c.title || "打卡")}>
{c.title || "打卡"} <Badge variant="secondary" className="ml-1">{c.type === "single" || c.type === "first" || c.type === "her" ? "单人" : "双人"}</Badge>
<div className="text-xs text-muted-foreground">我{c.completed_by_me ? "✅" : "⬜"} TA{c.completed_by_partner ? "✅" : "⬜"}</div>
</RecordItem>
))}
</TabsContent>
<TabsContent value="activity"><ActivityTab coupleId={coupleId!} /></TabsContent>
<TabsContent value="pet"><PetPanel pet={d.pet} /></TabsContent>
</Tabs>
)}
<DeleteContentDialog target={del} onClose={() => setDel(null)} onDone={() => { qc.invalidateQueries({ queryKey: ["admin_couple_detail", coupleId] }); qc.invalidateQueries({ queryKey: ["admin_contents"] }) }} />
</DialogContent>
</Dialog>
)
}
function ActivityTab({ coupleId }: { coupleId: number }) {
const q = useQuery({ queryKey: ["admin_activity_log", coupleId], queryFn: () => api<any[]>("adminActivityLog", { couple_id: coupleId }, "GET") })
if (q.isPending) return <Loading />
if (q.isError) return <ErrorBox error={q.error} />
if (!q.data.length) return <Empty icon="📋" text="暂无操作记录" />
return (
<div>
{q.data.map((a) => (
<RecordItem key={a.id} icon="👤" right={<span className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(a.created_at)}</span>}>
<strong className={a.gender === "male" ? "text-sky-500" : "text-rose-400"}>{a.nickname || "?"}</strong> {a.description}
</RecordItem>
))}
</div>
)
}
function Bar({ label, icon, val, color }: { label: string; icon: string; val: number; color: string }) {
const pct = Math.min(100, Math.max(0, val))
return (
<div className="my-1.5">
<div className="mb-0.5 flex justify-between text-xs"><span>{icon} {label}</span><span>{val}</span></div>
<div className="h-1.5 overflow-hidden rounded bg-muted"><div className="h-full rounded" style={{ width: `${pct}%`, background: color }} /></div>
</div>
)
}
// 宠物面板(情侣详情 / admin_couple_detail 返回的 pet 含 dailies/achievements 等)
export function PetPanel({ pet }: { pet: any }) {
if (!pet) return <Empty icon="🐾" text="该情侣尚未领养宠物" />
const stageClr = STAGE_COLOR[pet.stage - 1] || STAGE_COLOR[0]
const allDone = (d: any) => ["feed", "water", "walk", "bath", "sleep", "play"].every((k) => d[`${k}_u1`] && d[`${k}_u2`])
return (
<div className="space-y-4">
<div className="py-2 text-center">
<div className="text-6xl leading-tight">{BREED_EMOJI[pet.breed] || "🐕"}</div>
<div className="mt-2 text-xl font-bold">{pet.name}</div>
<div className="text-sm text-muted-foreground">{BREED[pet.breed] || pet.breed} · {pet.gender == 1 ? "♂ 男孩" : "♀ 女孩"} · Lv.{pet.level}</div>
<span className="mt-2 inline-block rounded-full px-4 py-1 text-xs font-semibold text-white" style={{ background: stageClr }}>{STAGE[pet.stage] || "幼犬期"}</span>
<div className="mt-1.5 text-xs text-muted-foreground">领养 {daysSince(pet.adopted_at)} 天</div>
</div>
<div className="px-1">
<Bar label="心情" icon="😊" val={pet.happiness} color="#FF6B9D" />
<Bar label="饱腹" icon="🍖" val={pet.hunger} color="#FFB347" />
<Bar label="精力" icon="⚡" val={pet.energy} color="#4ECDC4" />
<Bar label="清洁" icon="🧹" val={pet.cleanliness} color="#A8E6CF" />
</div>
{pet.achievements?.length > 0 && (
<div>
<div className="mb-2 text-sm font-semibold">🏆 成就 ({pet.achievements.length})</div>
<div className="flex flex-wrap gap-1.5">{pet.achievements.map((a: any) => <Badge key={a.id} variant="secondary">{a.achievement_key}</Badge>)}</div>
</div>
)}
{pet.dailies?.length > 0 && (
<div>
<div className="mb-2 text-sm font-semibold">📋 日常记录 (近60天)</div>
<div className="max-h-52 overflow-y-auto text-xs">
{pet.dailies.map((dd: any) => (
<div key={dd.id} className="flex justify-between border-b py-1.5 last:border-b-0"><span>{dd.date}</span><span>{allDone(dd) ? "✅ 全部完成" : "🔄 部分完成"}</span></div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,75 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react"
import { X } from "lucide-react"
import { mediaUrl } from "@/lib/api"
import { cn } from "@/lib/utils"
const Ctx = createContext<(url: string) => void>(() => {})
// 全局图片灯箱(对应旧版 previewImage)
export function ImagePreviewProvider({ children }: { children: ReactNode }) {
const [url, setUrl] = useState<string | null>(null)
const open = useCallback((u: string) => u && setUrl(u), [])
return (
<Ctx.Provider value={open}>
{children}
{url && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/85 p-4" onClick={() => setUrl(null)}>
<button className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20" aria-label="关闭">
<X className="size-5" />
</button>
<img src={url} className="max-h-[92vh] max-w-[95vw] rounded-lg object-contain shadow-2xl" onClick={(e) => e.stopPropagation()} />
</div>
)}
</Ctx.Provider>
)
}
export function useImagePreview() {
return useContext(Ctx)
}
// 可点击放大的缩略图
export function Thumb({ path, className, title }: { path?: string | null; className?: string; title?: string }) {
const preview = useImagePreview()
const url = mediaUrl(path)
if (!url) return null
return (
<img
src={url}
title={title}
loading="lazy"
className={cn("size-14 cursor-zoom-in rounded-lg border object-cover transition hover:opacity-80", className)}
onClick={(e) => {
e.stopPropagation()
preview(url)
}}
/>
)
}
export function ThumbGallery({ paths, className }: { paths: string[]; className?: string }) {
if (!paths.length) return null
return (
<div className={cn("flex flex-wrap gap-1.5", className)}>
{paths.map((p, i) => (
<Thumb key={i} path={p} />
))}
</div>
)
}
// 头像:有图显示图,无图显示首字母渐变圆
export function Avatar({ path, name, size = 36 }: { path?: string | null; name?: string | null; size?: number }) {
const preview = useImagePreview()
const url = mediaUrl(path)
const style = { width: size, height: size, fontSize: size * 0.42 }
if (url)
return (
<img src={url} style={style} className="shrink-0 cursor-zoom-in rounded-full object-cover" onClick={(e) => { e.stopPropagation(); preview(url) }} />
)
return (
<div style={style} className="flex shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-400 to-violet-400 font-semibold text-white">
{(name || "?").trim().charAt(0).toUpperCase()}
</div>
)
}
@@ -0,0 +1,45 @@
import { useState } from "react"
import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom"
import { useQueryClient, useIsFetching } from "@tanstack/react-query"
import { BarChart3, Users, Heart, FileText, Image, Unlink, PawPrint, Settings, LogOut, Menu, RefreshCw, ChevronRight, Bell } from "lucide-react"
import { toast } from "sonner"
import { api } from "@/lib/api"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
const NAV = [
{ to: "/", label: "数据概览", icon: BarChart3, group: "工作台", end: true },
{ to: "/users", label: "用户管理", icon: Users, group: "空间管理" },
{ to: "/couples", label: "情侣管理", icon: Heart, group: "空间管理" },
{ to: "/contents", label: "内容管理", icon: FileText, group: "空间管理" },
{ to: "/unbind", label: "解绑管理", icon: Unlink, group: "空间管理" },
{ to: "/pets", label: "宠物管理", icon: PawPrint, group: "空间管理" },
{ to: "/backgrounds", label: "背景管理", icon: Image, group: "系统" },
{ to: "/settings", label: "系统设置", icon: Settings, group: "系统" },
{ to: "/subscribe-messages", label: "订阅消息", icon: Bell, group: "系统" },
]
function NavItems({ onNavigate }: { onNavigate?: () => void }) {
return <nav className="admin-navigation">{["工作台", "空间管理", "系统"].map(group => <section key={group}><div className="nav-group-label">{group}</div>{NAV.filter(item => item.group === group).map(({to,label,icon: Icon,end}) => <NavLink key={to} to={to} end={end} onClick={onNavigate} className={({isActive}) => cn("admin-nav-link",isActive && "active")}><Icon size={18}/><span>{label}</span></NavLink>)}</section>)}</nav>
}
export default function Layout({ username }: { username: string }) {
const navigate = useNavigate(), location = useLocation(), qc = useQueryClient()
const [open, setOpen] = useState(false)
const fetching = useIsFetching()
const current = NAV.find(item => item.to === location.pathname)?.label || "管理后台"
async function logout() {
try { await api("adminLogout", null, "GET") }
catch { toast.error("退出失败,请重试"); return }
qc.clear(); toast.success("已退出"); navigate("/login", {replace:true})
}
const brand = <div className="admin-brand"><div className="brand-mark"><Heart size={22}/></div><div><strong>我们 past</strong><span>让陪伴有迹可循</span></div></div>
const account = <div className="sidebar-account"><div className="account-avatar">{username.slice(0,1).toUpperCase()}</div><div><strong>{username}</strong><span>管理员</span></div><Button variant="ghost" size="icon" aria-label="退出后台" onClick={logout}><LogOut size={17}/></Button></div>
return <div className="admin-shell">
<aside className="admin-sidebar">{brand}<NavItems/>{account}</aside>
<div className="admin-workspace">
<header className="admin-header"><div className="header-path"><Sheet open={open} onOpenChange={setOpen}><SheetTrigger asChild><Button variant="ghost" size="icon" className="md:hidden" aria-label="打开导航"><Menu size={20}/></Button></SheetTrigger><SheetContent side="left" className="w-72 p-5"><SheetTitle className="sr-only">管理导航</SheetTitle>{brand}<NavItems onNavigate={()=>setOpen(false)}/>{account}</SheetContent></Sheet><span className="hidden sm:inline">管理后台</span><ChevronRight size={14} className="hidden sm:block"/><strong>{current}</strong></div><div className="header-tools"><Button variant="outline" size="sm" onClick={()=>qc.invalidateQueries()} disabled={fetching>0}><RefreshCw className={cn("size-4",fetching>0&&"animate-spin")}/><span className="hidden sm:inline">刷新数据</span></Button><span className="header-account">{username}</span></div></header>
<main className="admin-main"><Outlet/></main>
<footer className="admin-footer">我们 past · 共同记录每一天</footer>
</div>
</div>
}
@@ -0,0 +1,43 @@
import { useState, type FormEvent } from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export const PAGE_SIZE = 10
// 后端分页响应的通用结构(admin_users / admin_couples / admin_unbind_requests / admin_pets / admin_contents)
export type Paged<T = any> = { items: T[]; total: number; page: number; page_size: number; total_pages: number }
// 分页条:上一页/下一页、页码窗口、跳转输入
export function Pagination({ page, totalPages, total, onChange }: { page: number; totalPages: number; total?: number; onChange: (p: number) => void }) {
const [jump, setJump] = useState("")
if (!total && totalPages <= 1) return null
const pages: number[] = []
for (let i = Math.max(1, page - 2); i <= Math.min(totalPages, page + 2); i++) pages.push(i)
const go = (p: number) => onChange(Math.min(Math.max(1, totalPages), Math.max(1, p)))
const submitJump = (e: FormEvent) => {
e.preventDefault()
const n = parseInt(jump, 10)
if (!isNaN(n)) go(n)
setJump("")
}
return (
<div className="flex flex-wrap items-center justify-between gap-3 pt-4 text-sm">
<span className="text-muted-foreground">
{total !== undefined && <>共 {total} 条 · </>}第 {page} / {Math.max(1, totalPages)} 页
</span>
<div className="flex flex-wrap items-center gap-1.5">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => go(page - 1)}><ChevronLeft className="size-4" />上一页</Button>
{pages[0] > 1 && (<><Button size="sm" variant="outline" onClick={() => go(1)}>1</Button>{pages[0] > 2 && <span className="px-1 text-muted-foreground">…</span>}</>)}
{pages.map((i) => <Button key={i} size="sm" variant={i === page ? "default" : "outline"} onClick={() => go(i)}>{i}</Button>)}
{pages.length > 0 && pages[pages.length - 1] < totalPages && (<>{pages[pages.length - 1] < totalPages - 1 && <span className="px-1 text-muted-foreground">…</span>}<Button size="sm" variant="outline" onClick={() => go(totalPages)}>{totalPages}</Button></>)}
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => go(page + 1)}>下一页<ChevronRight className="size-4" /></Button>
<form onSubmit={submitJump} className="ml-2 flex items-center gap-1.5">
<span className="text-muted-foreground">跳至</span>
<Input className="h-7 w-16 text-center" inputMode="numeric" value={jump} onChange={(e) => setJump(e.target.value.replace(/\D/g, ""))} placeholder="页码" />
<Button size="sm" variant="outline" type="submit">GO</Button>
</form>
</div>
</div>
)
}
@@ -0,0 +1,286 @@
import { useEffect, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { api, fmtTime, parseImages } from "@/lib/api"
import { Avatar, Thumb, ThumbGallery } from "@/components/ImagePreview"
import { Empty, Loading, ErrorBox, RecordItem, genderText } from "@/components/common"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
// ---------- 用户详情(含编辑资料、打卡/日记/相册记录)----------
export function UserDetailDialog({ userId, onClose, onDelete }: { userId: number | null; onClose: () => void; onDelete: (u: { id: number; name: string; hasPartner: boolean }) => void }) {
const qc = useQueryClient()
const q = useQuery({
queryKey: ["admin_user_detail", userId],
queryFn: () => api("adminUserDetail", { user_id: userId }, "GET"),
enabled: !!userId,
})
const d = q.data
const [form, setForm] = useState({ nickname: "", gender: "", age: "", birthday: "" })
useEffect(() => {
if (d) setForm({ nickname: d.nickname || "", gender: d.gender || "", age: d.age ? String(d.age) : "", birthday: d.birthday || "" })
}, [d])
async function save() {
if (!userId) return
try {
await api("adminUpdateUser", {
user_id: userId,
nickname: form.nickname,
gender: form.gender,
age: form.age === "" ? undefined : Number(form.age),
birthday: form.birthday,
})
toast.success("资料已更新 💕")
qc.invalidateQueries({ queryKey: ["admin_user_detail", userId] })
qc.invalidateQueries({ queryKey: ["admin_users"] })
} catch (e) {
toast.error((e as Error).message)
}
}
const logs = d?.checkin_logs || []
const checkins = d?.checkins || []
const diaries = d?.diaries || []
const albums = d?.albums || []
return (
<Dialog open={!!userId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>👤 用户详情</DialogTitle>
<DialogDescription>查看用户信息和记录</DialogDescription>
</DialogHeader>
{q.isPending ? <Loading /> : q.isError ? <ErrorBox error={q.error} /> : (
<div className="space-y-4">
<div className="flex items-center gap-4 rounded-xl bg-gradient-to-r from-pink-50 to-violet-50 p-4 dark:from-pink-950/30 dark:to-violet-950/30">
<Avatar path={d.avatar} name={d.nickname || d.username} size={56} />
<div className="min-w-0 text-sm">
<div className="font-semibold">
{d.nickname || "-"}
<span className="ml-2 text-xs font-normal text-muted-foreground">
#{d.user_id || "-"} · {d.openid ? "微信用户" : "手机用户"}{d.openid ? ` · 微信ID: ${d.openid}` : ""}
</span>
</div>
<div className="text-muted-foreground">{genderText(d.gender)} · {d.age > 0 ? `${d.age}岁` : "-"} · 🎂{d.birthday || "-"}</div>
<div className="text-xs text-muted-foreground">
📍 登录地址:{d.last_login_location || d.location || "未记录"}{d.last_login_at ? ` · 🕒 ${fmtTime(d.last_login_at)}` : ""}
</div>
</div>
</div>
<div className="rounded-xl border p-4">
<div className="mb-3 text-sm font-semibold">✏️ 编辑资料</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label>💖 昵称</Label><Input value={form.nickname} onChange={(e) => setForm({ ...form, nickname: e.target.value })} /></div>
<div className="space-y-1">
<Label>⚧ 性别</Label>
<Select value={form.gender || "none"} onValueChange={(v) => setForm({ ...form, gender: v === "none" ? "" : v })}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">-</SelectItem>
<SelectItem value="male">男</SelectItem>
<SelectItem value="female">女</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label>🎂 年龄</Label><Input type="number" value={form.age} onChange={(e) => setForm({ ...form, age: e.target.value })} /></div>
<div className="space-y-1"><Label>🎂 生日</Label><Input type="date" value={form.birthday} onChange={(e) => setForm({ ...form, birthday: e.target.value })} /></div>
</div>
<div className="mt-3 flex gap-2">
<Button className="flex-1" onClick={save}>保存修改 💕</Button>
<Button variant="destructive" className="flex-1" onClick={() => onDelete({ id: userId!, name: d.nickname || d.username || "该用户", hasPartner: !!d.couple_id })}>
🗑️ 删除此用户
</Button>
</div>
</div>
<Tabs defaultValue="checkin">
<TabsList className="w-full">
<TabsTrigger value="checkin" className="flex-1">✅ 打卡</TabsTrigger>
<TabsTrigger value="diary" className="flex-1">📝 日记</TabsTrigger>
<TabsTrigger value="album" className="flex-1">📷 相册</TabsTrigger>
</TabsList>
<TabsContent value="checkin">
{!logs.length && !checkins.length ? <Empty icon="✅" text="暂无打卡记录" /> : (
<div className="space-y-3">
{logs.length > 0 && (
<div>
<div className="mb-1 text-xs font-semibold text-muted-foreground">📋 打卡记录 ({logs.length}条)</div>
{logs.map((l: any) => (
<div key={l.id} className="border-b py-2 text-sm last:border-b-0">
<span className="text-muted-foreground">{fmtTime(l.created_at)}</span>
{l.checkin_title && <Badge variant="secondary" className="ml-2">{l.checkin_icon || "✅"} {l.checkin_title}</Badge>}
{l.note && <span className="ml-2 text-xs text-muted-foreground">📝{l.note}</span>}
{l.photo && <div className="mt-1"><Thumb path={l.photo} /></div>}
</div>
))}
</div>
)}
{checkins.length > 0 && (
<div>
<div className="mb-1 text-xs font-semibold text-muted-foreground">📌 打卡项 ({checkins.length}项)</div>
{checkins.map((c: any) => <CheckinItem key={c.id} c={c} />)}
</div>
)}
</div>
)}
</TabsContent>
<TabsContent value="diary">
{!diaries.length ? <Empty icon="📝" text="暂无" /> : diaries.map((dd: any) => (
<RecordItem key={dd.id} icon="📝" right={<span className="text-xs text-muted-foreground">{dd.diary_date}</span>} footer={<ThumbGallery paths={parseImages(dd.images)} />}>
{dd.title || "一篇日记"}
</RecordItem>
))}
</TabsContent>
<TabsContent value="album">
{!albums.length ? <Empty icon="📷" text="暂无" /> : albums.map((a: any) => (
<RecordItem key={a.id} icon="📷">{a.title}</RecordItem>
))}
</TabsContent>
</Tabs>
</div>
)}
</DialogContent>
</Dialog>
)
}
function CheckinItem({ c }: { c: any }) {
const [open, setOpen] = useState(false)
const logs = c.logs || []
return (
<div className="border-b py-2 text-sm last:border-b-0">
<div className="flex cursor-pointer items-center gap-2" onClick={() => setOpen(!open)}>
<span>✅</span>
<span>{c.title || "打卡"}</span>
<span className="text-xs text-muted-foreground">({logs.length}条记录)</span>
<Badge variant={c.is_completed ? "default" : "outline"} className="ml-auto">{c.is_completed ? "已完成" : "待完成"}</Badge>
<span className="text-xs text-muted-foreground">{open ? "▲" : "▼"}</span>
</div>
{open && (
<div className="mt-1 border-t border-dashed pl-6 pt-1">
{!logs.length ? <div className="py-1 text-xs text-muted-foreground">暂无打卡记录</div> : logs.map((l: any) => (
<div key={l.id} className="py-1 text-xs">
<span className="text-muted-foreground">{fmtTime(l.created_at)}</span>
{l.note && <span className="ml-2">📝{l.note}</span>}
{l.photo && <div className="mt-1"><Thumb path={l.photo} className="size-12" /></div>}
</div>
))}
</div>
)}
</div>
)
}
// ---------- 重置密码 ----------
export function ResetPasswordDialog({ user, onClose }: { user: { id: number; name: string } | null; onClose: () => void }) {
const [p1, setP1] = useState("")
const [p2, setP2] = useState("")
useEffect(() => { setP1(""); setP2("") }, [user])
async function submit() {
if (!user) return
if (p1.length < 6) return toast.error("新密码至少6位")
if (p1 !== p2) return toast.error("两次输入的密码不一致")
try {
await api("adminResetUserPassword", { user_id: user.id, new_password: p1 })
toast.success("密码重置成功 🔑")
onClose()
} catch (e) {
toast.error((e as Error).message)
}
}
return (
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>🔑 重置密码</DialogTitle>
<DialogDescription>为 {user?.name} 设置新密码</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label>新密码</Label><Input type="password" value={p1} onChange={(e) => setP1(e.target.value)} placeholder="6位以上" /></div>
<div className="space-y-1"><Label>确认密码</Label><Input type="password" value={p2} onChange={(e) => setP2(e.target.value)} /></div>
<Button className="w-full" onClick={submit}>确认重置</Button>
</div>
</DialogContent>
</Dialog>
)
}
// ---------- 删除用户确认 ----------
export function DeleteUserDialog({ user, onClose, onDone }: { user: { id: number; name: string; hasPartner: boolean } | null; onClose: () => void; onDone?: () => void }) {
const qc = useQueryClient()
async function confirm() {
if (!user) return
try {
const r = await api("adminDeleteUser", { user_id: user.id })
toast.success(r.msg || "已删除")
qc.invalidateQueries({ queryKey: ["admin_users"] })
qc.invalidateQueries({ queryKey: ["admin_couples"] })
qc.invalidateQueries({ queryKey: ["admin_stats"] })
onClose()
onDone?.()
} catch (e) {
toast.error((e as Error).message)
}
}
return (
<AlertDialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>⚠️ 确认删除用户「{user?.name}」</AlertDialogTitle>
<AlertDialogDescription>
{user?.hasPartner
? "该用户已绑定情侣:将同时删除其伴侣账号以及全部情侣数据(日记、相册、照片、打卡等)。此操作不可恢复!"
: "将删除该用户账号及其邀请记录。此操作不可恢复!"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={confirm}>确认删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// ---------- 删除内容确认(情侣详情 / 内容管理共用)----------
export type DeleteContentTarget = { type: string; id: number; coupleId: number; label: string }
export function DeleteContentDialog({ target, onClose, onDone }: { target: DeleteContentTarget | null; onClose: () => void; onDone?: () => void }) {
async function confirm() {
if (!target) return
try {
await api("adminDeleteContent", { type: target.type, id: target.id, couple_id: target.coupleId })
toast.success("已删除 🗑️")
onClose()
onDone?.()
} catch (e) {
toast.error((e as Error).message)
}
}
return (
<AlertDialog open={!!target} onOpenChange={(o) => !o && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>⚠️ 删除内容</AlertDialogTitle>
<AlertDialogDescription>确定要删除「{target?.label}」吗?删除后无法恢复!</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={confirm}>确认删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,73 @@
import { Inbox, AlertCircle } from "lucide-react"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
// 页面标题(右侧可放计数)
export function PageTitle({ children, count }: { children: ReactNode; count?: ReactNode }) {
return (
<h2 className="admin-page-title">
{children}
{count !== undefined && <span className="page-count">{count}</span>}
</h2>
)
}
export function Empty({ icon = "📋", text = "暂无数据", className }: { icon?: string; text?: string; className?: string }) {
return (
<div className={cn("admin-empty", className)}>
<div className="empty-mark">{icon === "❌" ? <AlertCircle size={26}/> : <Inbox size={26}/>}</div>
<p className="text-sm">{text}</p>
</div>
)
}
export function Loading({ rows = 4 }: { rows?: number }) {
return (
<div className="space-y-2 py-2">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
)
}
export function ErrorBox({ error }: { error: unknown }) {
return <Empty icon="❌" text={(error as Error)?.message || "加载失败"} />
}
// 统计卡片
export function StatCard({ label, value, tone = "pink" }: { label: string; value: ReactNode; tone?: "pink" | "violet" | "green" | "blue" | "amber" | "neutral" }) {
const tones: Record<string, string> = {
pink: "from-pink-50 to-pink-100 text-pink-600 dark:from-pink-950/40 dark:to-pink-900/30 dark:text-pink-300",
violet: "from-violet-50 to-violet-100 text-violet-600 dark:from-violet-950/40 dark:to-violet-900/30 dark:text-violet-300",
green: "from-emerald-50 to-emerald-100 text-emerald-600 dark:from-emerald-950/40 dark:to-emerald-900/30 dark:text-emerald-300",
blue: "from-sky-50 to-sky-100 text-sky-600 dark:from-sky-950/40 dark:to-sky-900/30 dark:text-sky-300",
amber: "from-amber-50 to-amber-100 text-amber-600 dark:from-amber-950/40 dark:to-amber-900/30 dark:text-amber-300",
neutral: "from-muted to-muted text-foreground",
}
return (
<div className={cn("rounded-xl bg-gradient-to-br p-5 text-left", tones[tone])}>
<div className="text-3xl font-semibold tracking-tight">{value}</div>
<div className="mt-1 text-xs text-muted-foreground">{label}</div>
</div>
)
}
// 记录列表项(对应旧版 .record-item)
export function RecordItem({ icon, children, right, footer, onClick }: { icon?: ReactNode; children: ReactNode; right?: ReactNode; footer?: ReactNode; onClick?: () => void }) {
return (
<div className={cn("border-b py-2.5 last:border-b-0", onClick && "cursor-pointer hover:bg-muted/50")} onClick={onClick}>
<div className="flex items-center gap-3">
{icon && <span className="w-6 text-center text-lg">{icon}</span>}
<div className="min-w-0 flex-1 text-sm">{children}</div>
{right}
</div>
{footer && <div className="pl-9 pt-1.5">{footer}</div>}
</div>
)
}
export function genderText(g?: string | null) {
return g === "male" ? "♂ 男" : g === "female" ? "♀ 女" : "-"
}
@@ -0,0 +1,197 @@
import * as React from "react"
import { cn } from "cn"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,102 @@
import * as React from "react"
import { cn } from "cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,166 @@
import * as React from "react"
import { cn } from "cn"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "cn"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
@@ -0,0 +1,191 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Select as SelectPrimitive } from "radix-ui"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "cn"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Dialog as SheetPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,13 @@
import { cn } from "cn"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
@@ -0,0 +1,113 @@
import * as React from "react"
import { cn } from "cn"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Tabs as TabsPrimitive } from "radix-ui"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

Some files were not shown because too many files have changed in this diff Show More