更新最新代码
This commit is contained in:
+18
@@ -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
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
run/
|
||||||
|
config.yaml
|
||||||
|
web/admin/node_modules/
|
||||||
|
# web/admin/dist 会被 embed,构建后需存在;发布时用 make all 生成
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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 每次请求执行)。
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("服务已停止")
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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; }
|
||||||
@@ -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
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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=数据已删除'"},
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+7630
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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
Reference in New Issue
Block a user