feat: 添加用户注册登录功能,实现数据和配置的用户隔离

- 新增 users 和 user_settings 表,repositories/analyses/review_notes 添加 user_id 列
- 实现基于邮箱+密码的注册登录,密码使用 bcrypt 哈希
- 使用 gin-contrib/sessions cookie-based session 管理
- 所有仓库、分析记录、review notes 按用户隔离
- 用户设置(LLM 配置、review 参数、缓存配置)独立存储
- 新增登录/注册页面,导航栏显示用户邮箱和退出按钮
- 前端 fetch 请求统一添加 credentials: 'same-origin'
- 支持 SESSION_SECRET 环境变量配置会话密钥
This commit is contained in:
2026-06-20 21:57:46 +08:00
parent 953c83d9aa
commit 9c843b79ba
28 changed files with 837 additions and 176 deletions
+8
View File
@@ -60,6 +60,14 @@
<a href="/" class="text-sm text-gray-600 hover:text-gray-900">首页</a>
<a href="/settings" class="text-sm text-gray-600 hover:text-gray-900">设置</a>
</div>
<div class="flex items-center space-x-4">
{{if .User}}
<span class="text-sm text-gray-500">{{.User.Email}}</span>
<form method="POST" action="/api/auth/logout" class="inline">
<button type="submit" class="text-sm text-gray-500 hover:text-gray-700">退出</button>
</form>
{{end}}
</div>
</div>
</div>
</nav>
+2 -2
View File
@@ -91,14 +91,14 @@
// Load refs on page load
async function loadRefs() {
try {
const resp = await fetch(`/api/repos/${repoId}/refs`);
const resp = await fetch(`/api/repos/${repoId}/refs`, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Failed to load refs');
const refs = await resp.json();
// Fetch recent commits for the "Recent Commits" group
let commits = [];
try {
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`);
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`, { credentials: 'same-origin' });
if (graphResp.ok) {
const gd = await graphResp.json();
commits = gd.commits || [];
+1
View File
@@ -88,6 +88,7 @@ document.getElementById('clone-form').addEventListener('submit', async function(
const resp = await fetch('/api/repos', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify({url: url})
});
+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
{{template "nav" .}}
<main class="flex-1 flex items-center justify-center">
<div class="w-full max-w-md px-4">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<h1 class="text-2xl font-bold text-center mb-6">登录</h1>
<form id="login-form" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
<input type="email" name="email" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="your@email.com">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<input type="password" name="password" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="••••••">
</div>
<div id="login-error" class="text-sm text-red-600 hidden"></div>
<button type="submit"
class="w-full bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
登录
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
还没有账号?<a href="/register" class="text-indigo-600 hover:text-indigo-700">注册</a>
</p>
</div>
</div>
</main>
{{template "footer" .}}
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async function(e) {
e.preventDefault();
const form = new FormData(this);
const errorEl = document.getElementById('login-error');
errorEl.classList.add('hidden');
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify({
email: form.get('email'),
password: form.get('password')
})
});
const data = await resp.json();
if (!resp.ok) {
errorEl.textContent = data.error || '登录失败';
errorEl.classList.remove('hidden');
return;
}
window.location.href = '/';
} catch (err) {
errorEl.textContent = '网络错误,请重试';
errorEl.classList.remove('hidden');
}
});
</script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
{{template "nav" .}}
<main class="flex-1 flex items-center justify-center">
<div class="w-full max-w-md px-4">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<h1 class="text-2xl font-bold text-center mb-6">注册</h1>
<form id="register-form" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
<input type="email" name="email" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="your@email.com">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<input type="password" name="password" required minlength="6"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="至少 6 位">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">确认密码</label>
<input type="password" name="confirm_password" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="再次输入密码">
</div>
<div id="register-error" class="text-sm text-red-600 hidden"></div>
<button type="submit"
class="w-full bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
注册
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
已有账号?<a href="/login" class="text-indigo-600 hover:text-indigo-700">登录</a>
</p>
</div>
</div>
</main>
{{template "footer" .}}
</div>
<script>
document.getElementById('register-form').addEventListener('submit', async function(e) {
e.preventDefault();
const form = new FormData(this);
const errorEl = document.getElementById('register-error');
errorEl.classList.add('hidden');
const password = form.get('password');
const confirmPassword = form.get('confirm_password');
if (password !== confirmPassword) {
errorEl.textContent = '两次输入的密码不一致';
errorEl.classList.remove('hidden');
return;
}
try {
const resp = await fetch('/api/auth/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify({
email: form.get('email'),
password: password
})
});
const data = await resp.json();
if (!resp.ok) {
errorEl.textContent = data.error || '注册失败';
errorEl.classList.remove('hidden');
return;
}
window.location.href = '/';
} catch (err) {
errorEl.textContent = '网络错误,请重试';
errorEl.classList.remove('hidden');
}
});
</script>
</body>
</html>
+3 -3
View File
@@ -177,7 +177,7 @@
async function init() {
try {
// Load repo info
const reposResp = await fetch('/api/repos');
const reposResp = await fetch('/api/repos', { credentials: 'same-origin' });
if (reposResp.ok) {
const repos = await reposResp.json();
const repo = repos.find(r => r.id == repoId || r.id === parseInt(repoId));
@@ -192,14 +192,14 @@
}
// Load refs
const refsResp = await fetch(`/api/repos/${repoId}/refs`);
const refsResp = await fetch(`/api/repos/${repoId}/refs`, { credentials: 'same-origin' });
if (refsResp.ok) {
currentRefs = await refsResp.json();
// Fetch recent commits independently
let commits = [];
try {
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`);
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`, { credentials: 'same-origin' });
if (graphResp.ok) { const gd = await graphResp.json(); commits = gd.commits || []; }
} catch (e) { /* ignore */ }
+5 -5
View File
@@ -129,14 +129,14 @@
// Load refs on page load
async function loadRefs() {
try {
const resp = await fetch(`/api/repos/${repoId}/refs`);
const resp = await fetch(`/api/repos/${repoId}/refs`, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Failed to load refs');
const refs = await resp.json();
// Fetch recent commits for the "Recent Commits" group
let commits = [];
try {
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`);
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`, { credentials: 'same-origin' });
if (graphResp.ok) {
const gd = await graphResp.json();
commits = gd.commits || [];
@@ -437,7 +437,7 @@
async function loadHistoryList() {
try {
const resp = await fetch(`/api/repos/${repoId}/review/analyses`);
const resp = await fetch(`/api/repos/${repoId}/review/analyses`, { credentials: 'same-origin' });
if (!resp.ok) return;
const analyses = await resp.json();
@@ -465,7 +465,7 @@
try {
// Fetch the review result
const resp = await fetch(`/api/repos/${repoId}/review/analyses/${analysisId}`);
const resp = await fetch(`/api/repos/${repoId}/review/analyses/${analysisId}`, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Failed to load review');
const data = await resp.json();
@@ -518,7 +518,7 @@
const head = data.head_ref;
// Load diff from API
const diffResp = await fetch(`/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`);
const diffResp = await fetch(`/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`, { credentials: 'same-origin' });
if (diffResp.ok) {
const diffData = await diffResp.json();
const diffString = diffData.diff || '';
+1
View File
@@ -87,6 +87,7 @@ document.getElementById('settings-form').addEventListener('submit', async functi
const resp = await fetch('/api/settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(data)
});
if (!resp.ok) throw new Error('保存失败');