feat: 两端用户名登录、账号管理与独立学习空间 (#2) #17

Merged
ila merged 6 commits from feat/2-account-foundation into main 2026-09-11 11:28:47 +08:00
82 changed files with 14319 additions and 30 deletions
+9
View File
@@ -0,0 +1,9 @@
# Copy to .env.local and supply local credentials. Never commit .env.local.
LEXGO_DB_HOST=127.0.0.1
LEXGO_DB_PORT=3308
LEXGO_DB_NAME=lexgo_dev
LEXGO_DB_USER=
LEXGO_DB_PASSWORD=
LEXGO_BOOTSTRAP_USERNAME=admin
LEXGO_BOOTSTRAP_PASSWORD=
LEXGO_LISTEN=127.0.0.1:8000
+3
View File
@@ -7,3 +7,6 @@ gitea.env
node_modules/
.venv/
dist/
.local/
server/lexgo.exe
server/lexgo
+3 -1
View File
@@ -258,7 +258,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- 治理模式:轻量。数据库:MySQL 8(用户于 2026-09-10 确认);具体小版本在工程验证后锁定。
- 远端:https://git.ilapage.cn/OPC/lexgo.git;分支 main。不得把邻接 dev_harness 工作区当成本项目工作区。
- 当前为需求和设计阶段,没有产品代码。管理端底座已确定为 D:/github_project/goadmin 的 go-admin/go-admin-ui;学习端技术方向已记录为 Vue 3 + TypeScript + Vite 独立工程;运行兼容性、Python NLP 仍待验证;默认学习语言已确认英语,不能冒充批准的产品实现。
- 工程基础 #2 已实现待用户验收:server 基于指定 go-admin 选用模型扩展账号/会话 API,admin 复用 go-admin-ui,learner 为独立 Vue 3 + TypeScript + Vite 工程。默认英语;阅读、导入、词典、复习及 Python NLP 尚未实现或验证。
- 原四份研究保留为历史参考;PostgreSQL 建议被 MySQL 8 决策覆盖,U/A/N 索引用于追踪而不是批准所有范围。
- 用户/语言数据所有权、Unicode 原文位置、任务和复习幂等、完整备份恢复是后续方案的必要验收边界。
- 当前 MCP 连接其他 Gitea 站点,需使用目标站点 API 时记录原因;凭据仅从安全配置进入进程。
@@ -275,3 +275,5 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- 原型尽量减少说明文字,与目标页面一致:产品页只保留实际字段、操作与必要反馈;功能编号、模拟边界和审核说明放在独立导览或工单。
- 当前 Quant-UX v1 已获用户验收(工单 #1 评论 7498),实施总览为 #16、单元工单为 #2~#15;LinguaCafe 对照与真实划词验证由 #4 承接,不能记作已完成。
- 学习端和管理端均使用账号(用户名)+密码登录,账号不要求邮箱格式,邮箱不作为必填登录标识;后端独立校验管理权限与本人学习数据归属。
- 已验证 MySQL 8.4.3,本机 127.0.0.1:3308;开发库 lexgo_dev、测试库 lexgo_test_issue2。密码只从环境或忽略的 .env.local 读取。迁移测试只能使用 lexgo_test_ 前缀专用库,不能借用其他数据库。
- 后端命令使用 `python scripts/server.py migrate|bootstrap|serve|build|test|test-integration`;仅显式 migrate 修改表。bootstrap 只接受尚无账号的 LexGo 库,不覆盖已有管理员。Go 1.26.5、Node 22.22.1、pnpm 9.15.1;两端分别构建。
+8 -2
View File
@@ -2,7 +2,7 @@
面向自托管场景的阅读式语言学习项目,规划提供内容导入、阅读查词、词汇与短语、复习和实例管理。
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。当前是需求和设计阶段,没有产品代码;管理端使用 `D:/github_project/goadmin` 的 go-admin/go-admin-ui;学习端采用 Vue 3 + TypeScript + Vite,独立工程、共用后端。默认学习语言为英语;配套构建和 Python NLP 仍待验证或确认;MVP 定位已确认“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认进入 MVP,X 系列暂不纳入。Quant-UX 桌面/手机原型 v1 已通过用户验收,四阶段实施工单已建立。
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已实现待验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。阅读、导入、词典与复习尚未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
- [文档入口](docs/README.md) · [线上 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Home)
- [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md)
@@ -11,6 +11,12 @@
- [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。
- [工作流](docs/01-workflow.md) · [开发与验证](docs/04-local-development-and-verification.md)
## 本地工程基础
运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`;随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。
本地入口:学习端 http://127.0.0.1:5173,管理端 http://127.0.0.1:5174。完整安装与测试命令见[开发与验证](docs/04-local-development-and-verification.md)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。
## 文档与治理
远端为 `https://git.ilapage.cn/OPC/lexgo.git`,默认分支 main。需要工单的任务以 Gitea 工单为单次任务事实来源;轻量直接实施项通过 Git 提交和结果报告留痕。长期规则以 Wiki 为主源,docs 为单向镜像;默认不创建任务归档。
@@ -22,4 +28,4 @@ python dev_scripts/harness.py sync --verify
python -m unittest discover -s tests -v
```
预期同步/严格检查成功且治理测试全部通过。产品尚无启动命令,治理测试不代表产品已实现。当前 MCP 指向另一 Gitea 站点,本轮回退目标站点 API,原因与证据边界见[初始化记录](docs/07-new-project-documentation-setup.md)。凭据只使用安全配置或环境变量,不写入仓库。
预期同步/严格检查成功且治理测试全部通过。产品验证另见开发与验证页面,治理测试不代表学习业务已实现。当前 MCP 指向另一 Gitea 站点,本轮回退目标站点 API,原因与证据边界见[初始化记录](docs/07-new-project-documentation-setup.md)。凭据只使用安全配置或环境变量,不写入仓库。
+1
View File
@@ -0,0 +1 @@
module.exports = { root: true, env: { browser: true, es2021: true, node: true }, extends: ['eslint:recommended', 'plugin:vue/vue3-essential'], parserOptions: { ecmaVersion: 2021, sourceType: 'module' } }
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.env*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 wenjianzhang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
module.exports = { presets: ['@vue/cli-plugin-babel/preset'] }
+39
View File
@@ -0,0 +1,39 @@
{
"name": "lexgo-admin",
"version": "0.1.0",
"private": true,
"license": "MIT",
"scripts": {
"dev": "vue-cli-service serve --host 127.0.0.1 --port 5174",
"build": "vue-cli-service build",
"test": "node --test tests/*.test.mjs",
"lint": "eslint --ext .js,.mjs,.vue src"
},
"dependencies": {
"vue": "^3.5.41",
"vue-router": "^4.6.4",
"vuex": "^4.1.0",
"element-plus": "^2.14.4",
"normalize.css": "8.0.1",
"core-js": "^3.50.0"
},
"devDependencies": {
"@babel/core": "7.28.3",
"@vue/cli-plugin-babel": "^5.0.9",
"@vue/cli-service": "^5.0.9",
"@vue/compiler-sfc": "^3.5.41",
"eslint": "7.32.0",
"eslint-plugin-vue": "^9.33.0",
"sass": "^1.102.0",
"sass-loader": "^13.3.3"
},
"packageManager": "pnpm@9.15.1",
"engines": {
"node": ">=22",
"pnpm": ">=9"
},
"browserslist": [
"> 1%",
"last 2 versions"
]
}
+8346
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>LexGo 管理</title></head><body><noscript>请启用 JavaScript。</noscript><div id="app"></div></body></html>
+7
View File
@@ -0,0 +1,7 @@
<template><router-view /></template>
<script>export default { name: 'LexGoAdmin' }</script>
<style lang="scss">
@use './styles/sidebar.scss';
html, body, #app { height: 100%; margin: 0; font-family: "Microsoft YaHei", sans-serif; color: #303133; }
* { box-sizing: border-box; }
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div style="padding: 0 15px;" @click="toggleClick">
<svg
:class="{'is-active':isActive}"
class="hamburger"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
>
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
</svg>
</div>
</template>
<script>
export default {
name: 'AppHamburger',
emits: ['toggleClick'],
props: {
isActive: {
type: Boolean,
default: false
}
},
methods: {
toggleClick() {
this.$emit('toggleClick')
}
}
}
</script>
<style scoped>
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
.hamburger.is-active {
transform: rotate(180deg);
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div :class="{'hidden':hidden}" class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:background="background"
:layout="layout"
:page-sizes="pageSizes"
:total="total"
v-bind="$attrs"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script>
import { scrollTo } from '@/utils/scroll-to'
export default {
name: 'PaginationComponent',
emits: ['update:page', 'update:limit', 'pagination'],
props: {
total: {
required: true,
type: Number
},
page: {
type: Number,
default: 1
},
limit: {
type: Number,
default: 20
},
pageSizes: {
type: Array,
default() {
return [10, 20, 30, 50]
}
},
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper'
},
background: {
type: Boolean,
default: true
},
autoScroll: {
type: Boolean,
default: true
},
hidden: {
type: Boolean,
default: false
}
},
computed: {
currentPage: {
get() {
return this.page
},
set(val) {
this.$emit('update:page', val)
}
},
pageSize: {
get() {
return this.limit
},
set(val) {
this.$emit('update:limit', val)
}
}
},
methods: {
handleSizeChange(val) {
this.$emit('pagination', { page: this.currentPage, limit: val })
if (this.autoScroll) {
scrollTo(0, 800)
}
},
handleCurrentChange(val) {
this.$emit('pagination', { page: val, limit: this.pageSize })
if (this.autoScroll) {
scrollTo(0, 800)
}
}
}
}
</script>
<style scoped>
.pagination-container {
background: #fff;
padding: 32px 16px;
}
.pagination-container.hidden {
display: none;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="basic-layout">
<slot name="wrapper" />
</div>
</template>
<script>
export default {
name: 'BasicLayout'
}
</script>
<style lang="scss" scoped>
.basic-layout{
padding: 10px;
box-sizing: border-box;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<template><section class="app-main"><router-view :key="$store.state.generation" /></section></template>
<script>export default { name: 'AppMain' }</script>
<style scoped>.app-main { min-height: calc(100vh - 56px); width: 100%; position: relative; overflow: hidden; background-color: #f0f2f5; }</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div :class="classObj" class="app-wrapper">
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
<aside class="sidebar-container">
<div class="brand">{{ sidebar.opened ? 'LexGo 管理' : 'LG' }}</div>
<el-menu default-active="/accounts" :collapse="!sidebar.opened" router>
<el-menu-item index="/accounts"><span>账号管理</span></el-menu-item>
</el-menu>
</aside>
<div class="main-container">
<header class="navbar">
<button class="menu-toggle" aria-label="切换导航" @click="$store.dispatch('app/toggleSideBar')"><hamburger :is-active="sidebar.opened" /></button>
<span>账号管理</span>
<div class="right-menu"><span>{{ $store.state.user?.username }}</span><el-button @click="logout">退出登录</el-button></div>
</header>
<app-main />
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import ResizeMixin from './mixin/ResizeHandler'
import Hamburger from '../components/Hamburger/index.vue'
import AppMain from './components/AppMain.vue'
import { session } from '../store'
import { ElMessage } from 'element-plus'
export default {
name: 'MainLayout', components: { Hamburger, AppMain }, mixins: [ResizeMixin],
computed: {
...mapState({ sidebar: state => state.app.sidebar, device: state => state.app.device }),
classObj() { return { hideSidebar: !this.sidebar.opened, openSidebar: this.sidebar.opened, withoutAnimation: this.sidebar.withoutAnimation, mobile: this.device === 'mobile' } }
},
watch: { '$store.state.user'(user) { if (!user) this.$router.replace('/login') } },
methods: {
handleClickOutside() { this.$store.dispatch('app/closeSideBar', { withoutAnimation: false }) },
async logout() {
try { await session.logout() } catch { ElMessage.warning('本地已退出;服务器尚未确认退出,原会话可能仍有效。') }
this.$router.replace('/login')
}
}
}
</script>
<style lang="scss" scoped>
@use '@/styles/mixin.scss' as *;
.app-wrapper { @include clearfix; position: relative; height: 100%; width: 100%; &.mobile.openSidebar { position: fixed; top: 0; } }
.drawer-bg { background: #000; opacity: .3; width: 100%; top: 0; height: 100%; position: absolute; z-index: 999; }
.navbar { height: 56px; display: flex; align-items: center; gap: 12px; background: white; padding-right: 16px; box-shadow: 0 1px 4px #00152914; }
.right-menu { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.brand { height: 64px; display: flex; align-items: center; justify-content: center; font-size: 18px; color: white; font-weight: 600; }
.menu-toggle { background: transparent; border: 0; cursor: pointer; }
</style>
+45
View File
@@ -0,0 +1,45 @@
import store from '@/store'
const { body } = document
const WIDTH = 992 // refer to Bootstrap's responsive design
export default {
watch: {
$route() {
if (this.device === 'mobile' && this.sidebar.opened) {
store.dispatch('app/closeSideBar', { withoutAnimation: false })
}
}
},
beforeMount() {
window.addEventListener('resize', this.$_resizeHandler)
},
beforeUnmount() {
window.removeEventListener('resize', this.$_resizeHandler)
},
mounted() {
const isMobile = this.$_isMobile()
if (isMobile) {
store.dispatch('app/toggleDevice', 'mobile')
store.dispatch('app/closeSideBar', { withoutAnimation: true })
}
},
methods: {
// use $_ for mixins properties
// https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
$_isMobile() {
const rect = body.getBoundingClientRect()
return rect.width - 1 < WIDTH
},
$_resizeHandler() {
if (!document.hidden) {
const isMobile = this.$_isMobile()
store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop')
if (isMobile) {
store.dispatch('app/closeSideBar', { withoutAnimation: true })
}
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'normalize.css/normalize.css'
import 'element-plus/dist/index.css'
import App from './App.vue'
import store from './store'
import router from './router'
createApp(App).use(store).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { session } from '../store'
import Layout from '../layout/index.vue'
import Login from '../views/Login.vue'
import Accounts from '../views/Accounts.vue'
const router = createRouter({ history: createWebHashHistory(), routes: [
{ path: '/login', component: Login },
{ path: '/', component: Layout, children: [{ path: '', redirect: '/accounts' }, { path: 'accounts', component: Accounts }] },
{ path: '/:pathMatch(.*)*', redirect: '/accounts' }
] })
router.beforeEach(async to => {
if (to.path === '/login') return true
try {
if (!session.state.user && !await session.restore()) return '/login'
if (session.state.user?.role !== 'admin') return '/login'
} catch { return '/login' }
})
export default router
+109
View File
@@ -0,0 +1,109 @@
export function normalizeUsername(value) { return value.trim().toLowerCase() }
export function validPassword(value) {
const bytes = new TextEncoder().encode(value).length
return bytes >= 6 && bytes <= 72
}
export function validUsername(value) { return /^[a-z][a-z0-9_.-]{2,31}$/.test(normalizeUsername(value)) }
export function createSession({ fetch, storage, changed = () => {} }) {
const key = 'lexgo-admin-token'
const state = { token: storage.getItem(key) || '', user: null, accounts: [], generation: 0 }
function clear() {
state.generation++
state.token = ''
state.user = null
state.accounts = []
storage.removeItem(key)
changed(state)
}
function assertCurrent(generation) {
if (generation !== state.generation) throw new Error('会话已变化,请重新操作')
}
async function request(path, method = 'GET', body, token = state.token, generation = state.generation) {
const result = await fetch('/api/v1' + path, {
method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
cache: 'no-store'
})
const payload = await result.json()
assertCurrent(generation)
if (!result.ok) {
if (result.status === 401 || result.status === 403) clear()
throw new Error(result.status === 401 ? '登录已失效,请重新登录' : payload.msg || '请求失败')
}
return payload.data
}
async function revoke(token) {
// Cleanup must never restore a previous identity or expose its response.
if (!token) return
let result
try {
result = await fetch('/api/v1/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + token } })
} catch {
throw new Error('服务器尚未确认退出,原会话可能仍有效')
}
if (!result.ok && result.status !== 401) throw new Error('服务器尚未确认退出,原会话可能仍有效')
}
async function requireAdmin(user, token) {
if (user.role !== 'admin') {
clear()
try { await revoke(token) } catch (error) { throw new Error('此账号没有管理权限;' + error.message) }
throw new Error('此账号没有管理权限')
}
}
function authorized() { if (!state.user || state.user.role !== 'admin') throw new Error('请先登录管理员账号') }
return {
state, clear,
async login(username, password) {
const oldToken = state.token
clear()
const generation = state.generation
if (oldToken) await revoke(oldToken)
assertCurrent(generation)
const data = await request('/login', 'POST', { username: normalizeUsername(username), password }, '', generation)
assertCurrent(generation)
await requireAdmin(data.user, data.token)
assertCurrent(generation)
state.token = data.token
state.user = data.user
storage.setItem(key, data.token)
changed(state)
},
async restore() {
if (!state.token) return false
const generation = state.generation
try {
const user = await request('/me')
assertCurrent(generation)
await requireAdmin(user, state.token)
assertCurrent(generation)
state.user = user
changed(state)
return true
} catch (error) { if (generation === state.generation) clear(); throw error }
},
async logout() { const token = state.token; clear(); await revoke(token) },
async loadAccounts() {
authorized()
const generation = state.generation
const data = await request('/accounts')
assertCurrent(generation)
state.accounts = data.items
changed(state)
},
async createAccount(username, password) {
authorized()
if (!validUsername(username)) throw new Error('账号须为 3–32 位,以字母开头,仅含字母、数字、点、下划线或短横线')
if (!validPassword(password)) throw new Error('密码须为 6–72 字节')
return request('/accounts', 'POST', { username: normalizeUsername(username), password })
},
async updateAccount(id, patch) {
authorized()
if (patch.password !== undefined && !validPassword(patch.password)) throw new Error('密码须为 6–72 字节')
if (id === state.user.id && patch.disabled) throw new Error('不能停用当前管理员')
const account = state.accounts.find(item => item.id === id)
if (id === state.user.id || (account && account.role !== 'learner')) throw new Error('管理员账号不支持此操作')
return request('/accounts/' + id, 'PATCH', patch)
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import { createStore } from 'vuex'
import { createSession } from '../session.mjs'
const store = createStore({
state: { user: null, accounts: [], generation: 0 },
mutations: { session(state, next) { state.user = next.user; state.accounts = next.accounts; state.generation = next.generation } },
modules: {
app: {
namespaced: true,
state: () => ({ sidebar: { opened: true, withoutAnimation: false }, device: 'desktop' }),
mutations: {
close(state, value) { state.sidebar.opened = false; state.sidebar.withoutAnimation = value.withoutAnimation },
toggle(state) { state.sidebar.opened = !state.sidebar.opened },
device(state, value) { state.device = value }
},
actions: {
closeSideBar({ commit }, value) { commit('close', value) },
toggleSideBar({ commit }) { commit('toggle') },
toggleDevice({ commit }, value) { commit('device', value) }
}
}
}
})
export const session = createSession({ fetch: window.fetch.bind(window), storage: window.sessionStorage, changed: state => store.commit('session', state) })
export default store
+66
View File
@@ -0,0 +1,66 @@
@mixin clearfix {
&:after {
content: "";
display: table;
clear: both;
}
}
@mixin scrollBar {
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
@mixin relative {
position: relative;
width: 100%;
height: 100%;
}
@mixin pct($pct) {
width: #{$pct};
position: relative;
margin: 0 auto;
}
@mixin triangle($width, $height, $color, $direction) {
$width: $width*0.5;
$color-border-style: $height solid $color;
$transparent-border-style: $width solid transparent;
height: 0;
width: 0;
@if $direction==up {
border-bottom: $color-border-style;
border-left: $transparent-border-style;
border-right: $transparent-border-style;
}
@else if $direction==right {
border-left: $color-border-style;
border-top: $transparent-border-style;
border-bottom: $transparent-border-style;
}
@else if $direction==down {
border-top: $color-border-style;
border-left: $transparent-border-style;
border-right: $transparent-border-style;
}
@else if $direction==left {
border-right: $color-border-style;
border-top: $transparent-border-style;
border-bottom: $transparent-border-style;
}
}
+263
View File
@@ -0,0 +1,263 @@
@use './variables.scss' as *;
#app {
.main-container {
min-height: 100%;
transition: margin-left .28s;
margin-left: $sideBarWidth;
position: relative;
}
.sidebar-container {
-webkit-transition: width .28s;
transition: width 0.28s;
width: $sideBarWidth !important;
background-color: $menuBg;
height: 100%;
position: fixed;
font-size: 0px;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;
-webkit-box-shadow: 2px 0 6px rgba(0, 21, 41, .35);
box-shadow: 2px 0 6px rgba(0, 21, 41, .35);
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
}
.scrollbar-wrapper {
overflow-x: hidden !important;
}
.el-scrollbar__bar.is-vertical {
right: 0px;
}
.el-scrollbar {
height: 100%;
}
&.has-logo {
.el-scrollbar {
height: calc(100% - 64px);
}
}
.is-horizontal {
display: none;
}
a {
display: inline-block;
width: 100%;
overflow: hidden;
}
.svg-icon {
margin-right: 16px;
}
.el-menu {
border: none;
height: 100%;
width: 100% !important;
--el-menu-bg-color: #{$menuBg};
background-color: $menuBg !important;
border-right: none !important;
}
// 统一菜单项高度(Element Plus 2.x 默认 56px 偏大,收紧至 50px)
.el-menu-item,
.el-sub-menu__title,
.el-submenu__title {
height: 50px !important;
line-height: 50px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
color: #fff !important;
}
// 顶级 el-menu-item(如"首页")必须设置深色背景,否则白字不可见
.el-menu-item {
background-color: $menuBg !important;
&:hover {
background-color: $menuHover !important;
}
&.is-active {
background-color: $subMenuBg !important;
color: $menuActiveText !important;
}
}
// 展开的子菜单 el-menu-item 强制暗色背景 + 白色文字
.el-sub-menu .el-menu-item,
.el-submenu .el-menu-item {
background-color: $subMenuBg !important;
color: #fff !important;
min-width: $sideBarWidth !important;
&:hover {
background-color: $subMenuHover !important;
}
&.is-active {
color: $menuActiveText !important;
}
}
// 嵌套子菜单标题
.nest-menu .el-sub-menu > .el-sub-menu__title,
.nest-menu .el-submenu > .el-submenu__title {
background-color: $subMenuBg !important;
color: #fff !important;
min-width: $sideBarWidth !important;
&:hover {
background-color: $subMenuHover !important;
}
}
// 父级菜单标题 hover 效果
.submenu-title-noDropdown,
.el-sub-menu__title,
.el-submenu__title {
&:hover {
background-color: $menuHover !important;
}
}
.is-active > .el-sub-menu__title,
.is-active > .el-submenu__title {
color: $subMenuActiveText !important;
}
}
.hideSidebar {
.sidebar-container {
width: 54px !important;
}
.main-container {
margin-left: 54px;
}
.submenu-title-noDropdown {
padding: 0 !important;
position: relative;
.el-tooltip {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
}
.el-sub-menu,
.el-submenu {
overflow: hidden;
> .el-sub-menu__title,
> .el-submenu__title {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
}
.el-menu--collapse {
.el-sub-menu,
.el-submenu {
> .el-sub-menu__title,
> .el-submenu__title {
> span {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
}
}
}
}
.el-menu--collapse .el-menu .el-sub-menu,
.el-menu--collapse .el-menu .el-submenu {
min-width: $sideBarWidth !important;
}
// mobile responsive
.mobile {
.main-container {
margin-left: 0px;
}
.sidebar-container {
transition: transform .28s;
width: $sideBarWidth !important;
}
&.hideSidebar {
.sidebar-container {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-$sideBarWidth, 0, 0);
}
}
}
.withoutAnimation {
.main-container,
.sidebar-container {
transition: none;
}
}
}
// when menu collapsed
.el-menu--vertical {
> .el-menu {
.svg-icon {
margin-right: 16px;
}
}
.nest-menu .el-sub-menu > .el-sub-menu__title,
.nest-menu .el-submenu > .el-submenu__title,
.el-menu-item {
&:hover {
background-color: rgba(0, 0, 0, 0.06) !important;
}
}
// the scroll bar appears when the subMenu is too long
> .el-menu--popup {
max-height: 100vh;
overflow-y: auto;
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// base color
$blue:#324157;
$light-blue:#3A71A8;
$red:#C03639;
$pink: #E65D6E;
$green: #30B08F;
$tiffany: #4AB7BD;
$yellow:#FEC171;
$panGreen: #30B08F;
// sidebar — Ant Design Pro 经典深蓝配色
$menuActiveText:#1677ff;
$subMenuActiveText:#69b1ff;
$menuBg:#001529;
$menuHover:#000c17;
$sidebarTitle: #ffffff;
$menuLightBg:#ffffff;
$menuLightHover:#e6f4ff;
$sidebarLightTitle: #001d66;
$subMenuBg:#000c17;
$subMenuHover:#001528;
$sideBarWidth: 210px;
// the :export directive is the magic sauce for webpack
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
:export {
// menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
menuLightBg: $menuLightBg;
menuLightHover: $menuLightHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
sidebarTitle: $sidebarTitle;
sidebarLightTitle: $sidebarLightTitle
}
+58
View File
@@ -0,0 +1,58 @@
Math.easeInOutQuad = function(t, b, c, d) {
t /= d / 2
if (t < 1) {
return c / 2 * t * t + b
}
t--
return -c / 2 * (t * (t - 2) - 1) + b
}
// requestAnimationFrame for Smart Animating http://goo.gl/sx5sts
var requestAnimFrame = (function() {
return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) }
})()
/**
* Because it's so fucking difficult to detect the scrolling element, just move them all
* @param {number} amount
*/
function move(amount) {
document.documentElement.scrollTop = amount
document.body.parentNode.scrollTop = amount
document.body.scrollTop = amount
}
function position() {
return document.documentElement.scrollTop || document.body.parentNode.scrollTop || document.body.scrollTop
}
/**
* @param {number} to
* @param {number} duration
* @param {Function} callback
*/
export function scrollTo(to, duration, callback) {
const start = position()
const change = to - start
const increment = 20
let currentTime = 0
duration = (typeof (duration) === 'undefined') ? 500 : duration
var animateScroll = function() {
// increment the time
currentTime += increment
// find the value with the quadratic in-out easing function
var val = Math.easeInOutQuad(currentTime, start, change, duration)
// move the document.body
move(val)
// do the animation unless its over
if (currentTime < duration) {
requestAnimFrame(animateScroll)
} else {
if (callback && typeof (callback) === 'function') {
// the animation is done so lets callback
callback()
}
}
}
animateScroll()
}
+72
View File
@@ -0,0 +1,72 @@
<template>
<basic-layout><template #wrapper><el-card class="box-card">
<div class="toolbar"><h1>账号管理</h1><el-button type="primary" @click="openCreate">新增学习账号</el-button><el-button :loading="loading" @click="load">刷新</el-button></div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-table v-loading="loading" :data="pageItems" border empty-text="暂无账号">
<el-table-column label="编号" width="75" prop="id" />
<el-table-column label="账号" prop="username" min-width="170" :show-overflow-tooltip="true" />
<el-table-column label="角色" width="110"><template #default="scope">{{ scope.row.role === 'admin' ? '管理员' : '学习者' }}</template></el-table-column>
<el-table-column label="状态" width="90"><template #default="scope"><el-tag :type="scope.row.disabled ? 'info' : 'success'">{{ scope.row.disabled ? '已停用' : '正常' }}</el-tag></template></el-table-column>
<el-table-column label="操作" min-width="220"><template #default="scope">
<el-button link type="primary" :disabled="busy || scope.row.role !== 'learner'" @click="changeStatus(scope.row)">{{ scope.row.disabled ? '启用' : '停用' }}</el-button>
<el-button link type="primary" :disabled="busy || scope.row.role !== 'learner'" @click="openReset(scope.row)">重置密码</el-button>
</template></el-table-column>
</el-table>
<pagination v-show="accounts.length" v-model:page="page" v-model:limit="limit" :total="accounts.length" :auto-scroll="false" layout="total, prev, pager, next" />
<el-dialog v-model="open" :title="target ? '重置密码 · ' + target.username : '新增学习账号'" width="min(460px, 92vw)" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @closed="clearForm">
<el-alert v-if="formError" :title="formError" type="error" :closable="false" />
<el-form :model="form" label-position="top" @submit.prevent="save">
<el-form-item v-if="!target" label="账号"><el-input v-model="form.username" autocomplete="off" maxlength="32" /><div class="hint">3–32 位,以字母开头;可含数字、点、下划线、短横线</div></el-form-item>
<el-form-item label="新密码"><el-input v-model="form.password" type="password" show-password autocomplete="new-password" /><div class="hint">6–72 字节;中文等字符会占多个字节</div></el-form-item>
<div class="dialog-footer"><el-button :disabled="busy" @click="open = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy">保存</el-button></div>
</el-form>
</el-dialog>
</el-card></template></basic-layout>
</template>
<script>
import BasicLayout from '../layout/BasicLayout.vue'
import Pagination from '../components/Pagination/index.vue'
import { session } from '../store'
import { ElMessage, ElMessageBox } from 'element-plus'
export default {
name: 'AccountManagement', components: { BasicLayout, Pagination },
data: () => ({ loading: false, busy: false, error: '', formError: '', open: false, target: null, form: { username: '', password: '' }, page: 1, limit: 20 }),
computed: {
accounts() { return this.$store.state.accounts },
pageItems() { return this.accounts.slice((this.page - 1) * this.limit, this.page * this.limit) }
},
mounted() { this.load() },
methods: {
async load() {
this.loading = true; this.error = ''
try { await session.loadAccounts() } catch (error) { this.error = error.message }
finally { this.loading = false }
},
clearForm() { this.form = { username: '', password: '' }; this.formError = ''; this.target = null },
openCreate() { this.clearForm(); this.open = true },
openReset(account) { if (account.role !== 'learner') return; this.clearForm(); this.target = account; this.open = true },
async save() {
if (this.busy) return
this.busy = true; this.formError = ''
try {
if (this.target) await session.updateAccount(this.target.id, { password: this.form.password })
else await session.createAccount(this.form.username, this.form.password)
this.open = false; this.clearForm(); ElMessage.success('已保存'); await this.load()
} catch (error) { this.formError = error.message }
finally { this.busy = false }
},
async changeStatus(account) {
if (account.role !== 'learner' || this.busy) return
const generation = session.state.generation
try { await ElMessageBox.confirm('确认' + (account.disabled ? '启用' : '停用') + '账号 ' + account.username + '?', '账号状态', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }) }
catch { return }
if (generation !== session.state.generation) return
this.busy = true; this.error = ''
try { await session.updateAccount(account.id, { disabled: !account.disabled }); await this.load() }
catch (error) { this.error = error.message }
finally { this.busy = false }
}
}
}
</script>
<style scoped>.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; flex-wrap: wrap; } h1 { font-size: 20px; margin: 0 auto 0 0; } .hint { color: #606266; font-size: 12px; } .dialog-footer { display: flex; justify-content: flex-end; } .el-alert { margin-bottom: 16px; }</style>
+31
View File
@@ -0,0 +1,31 @@
<template>
<main class="login-page"><div class="form-box">
<h1>LexGo 管理</h1><p>账号登录</p>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-form :model="form" label-position="top" class="login-form" @submit.prevent="login">
<el-form-item label="账号"><el-input v-model="form.username" name="username" autocomplete="username" autofocus /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" name="password" type="password" show-password autocomplete="current-password" /></el-form-item>
<el-button native-type="submit" type="primary" :loading="loading" :disabled="!form.username.trim() || !form.password">登录</el-button>
</el-form>
</div></main>
</template>
<script>
import { session } from '../store'
export default {
name: 'AdminLogin', data: () => ({ form: { username: '', password: '' }, loading: false, error: '' }),
methods: {
async login() {
if (this.loading) return
this.loading = true; this.error = ''
try { await session.login(this.form.username, this.form.password); this.$router.replace('/accounts') }
catch (error) { this.error = error.message }
finally { this.form.password = ''; this.loading = false }
}
}
}
</script>
<style scoped>
.login-page { min-height: 100vh; display: grid; place-items: center; background: #f0f2f5; padding: 24px; }
.form-box { background: white; width: min(420px, 100%); padding: 36px; border-radius: 8px; box-shadow: 0 6px 24px #00152910; }
h1 { font-size: 24px; } p { color: #606266; } .login-form { margin-top: 24px; } .login-form .el-button { width: 100%; }
</style>
+227
View File
@@ -0,0 +1,227 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createSession, normalizeUsername, validPassword } from '../src/session.mjs'
const admin = { id: 1, username: 'fixture.admin', role: 'admin' }
const learner = { id: 2, username: 'fixture.learner', role: 'learner' }
const response = (data, status = 200) => ({ ok: status < 400, status, json: async () => ({ code: status, data, msg: '请求失败' }) })
function setup(fetch) {
const data = new Map()
const storage = { getItem: key => data.get(key), setItem: (key, value) => data.set(key, value), removeItem: key => data.delete(key) }
return { session: createSession({ fetch, storage }), data }
}
test('normalizes account names and enforces UTF-8 password byte limits', () => {
assert.equal(normalizeUsername(' Fixture.Admin '), 'fixture.admin')
assert.equal(validPassword('虚构测试密码'), true)
assert.equal(validPassword('短'), false)
assert.equal(validPassword('虚'.repeat(25)), false)
})
test('creation and reset accept six bytes but reject five and over 72', async () => {
const writes = []
const { session } = setup(async (url, options) => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
writes.push(JSON.parse(options.body))
return response(learner)
})
await session.login(admin.username, 'fictional-password')
await assert.rejects(session.createAccount('fixture.new', 'z'.repeat(5)))
await assert.rejects(session.createAccount('fixture.new', 'z'.repeat(73)))
await session.createAccount('fixture.new', 'z'.repeat(6))
await session.createAccount('fixture.unicode', '虚构')
await session.createAccount('fixture.maximum', 'z'.repeat(72))
assert.equal(writes.length, 3)
await assert.rejects(session.updateAccount(learner.id, { password: 'z'.repeat(5) }))
await assert.rejects(session.updateAccount(learner.id, { password: 'z'.repeat(73) }))
for (const password of ['z'.repeat(6), '虚构', 'z'.repeat(72)]) {
await session.updateAccount(learner.id, { password })
assert.equal(writes.at(-1).password, password)
}
})
test('learner login is rejected, revoked, and never stored in admin session', async () => {
const requests = []
const { session, data } = setup(async (url, options) => {
requests.push([url, options])
return response(url.endsWith('/login') ? { token: 'fictional-learner-token', user: learner } : null)
})
await assert.rejects(session.login('fixture.learner', 'fictional-password'), /管理权限/)
assert.equal(session.state.user, null)
assert.equal(data.size, 0)
assert.equal(requests[1][0], '/api/v1/logout')
})
test('admin account list sends bearer and clears private data on 401', async () => {
let expire = false
const { session, data } = setup(async (url, options) => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
assert.equal(options.headers.Authorization, 'Bearer fictional-admin-token')
return expire ? response(null, 401) : response({ items: [learner] })
})
await session.login('fixture.admin', 'fictional-password')
await session.loadAccounts()
assert.deepEqual(session.state.accounts, [learner])
expire = true
await assert.rejects(session.loadAccounts())
assert.equal(session.state.user, null)
assert.deepEqual(session.state.accounts, [])
assert.equal(data.size, 0)
})
test('logout discards an in-flight account response', async () => {
let complete
const { session } = setup(async url => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
if (url.endsWith('/accounts')) return new Promise(resolve => { complete = resolve })
return response(null)
})
await session.login('fixture.admin', 'fictional-password')
const pending = session.loadAccounts()
await session.logout()
complete(response({ items: [learner] }))
await assert.rejects(pending, /会话已变化/)
assert.deepEqual(session.state.accounts, [])
})
test('account creation and state/password update use bounded API contract', async () => {
const writes = []
const { session } = setup(async (url, options) => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
writes.push([url, options.method, JSON.parse(options.body)])
return response(learner)
})
await session.login('fixture.admin', 'fictional-password')
await session.createAccount(' Fixture.Learner ', 'fictional-password')
await session.updateAccount(2, { disabled: true })
await session.updateAccount(2, { password: 'fictional-new-password' })
assert.deepEqual(writes, [
['/api/v1/accounts', 'POST', { username: 'fixture.learner', password: 'fictional-password' }],
['/api/v1/accounts/2', 'PATCH', { disabled: true }],
['/api/v1/accounts/2', 'PATCH', { password: 'fictional-new-password' }]
])
})
test('unauthenticated account access does not reach the API', async () => {
const { session } = setup(async () => assert.fail('must not call API'))
await assert.rejects(session.loadAccounts(), /请先登录/)
})
test('old restore failure cannot clear a newly logged in admin', async () => {
let complete
const { session, data } = setup(async url => {
if (url.endsWith('/me')) return new Promise(resolve => { complete = resolve })
if (url.endsWith('/login')) return response({ token: 'fictional-new-token', user: admin })
return response(null)
})
session.state.token = 'fictional-old-token'
const pending = session.restore()
await session.login('fixture.admin', 'fictional-password')
complete(response(null, 401))
await assert.rejects(pending)
assert.equal(session.state.user?.id, admin.id)
assert.equal(data.get('lexgo-admin-token'), 'fictional-new-token')
})
test('restored learner session is revoked and removed', async () => {
let revoked = false
const { session } = setup(async url => {
if (url.endsWith('/logout')) { revoked = true; return response(null) }
return response(learner)
})
session.state.token = 'fictional-learner-token'
await assert.rejects(session.restore(), /管理权限/)
assert.equal(session.state.user, null)
assert.equal(session.state.token, '')
assert.equal(revoked, true)
})
test('duplicate account error remains actionable without ending the admin session', async () => {
const { session } = setup(async url => url.endsWith('/login')
? response({ token: 'fictional-admin-token', user: admin })
: { ok: false, status: 409, json: async () => ({ code: 409, msg: '账号已存在' }) })
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.createAccount('fixture.learner', 'fictional-password'), /账号已存在/)
assert.equal(session.state.user.id, admin.id)
})
test('admin self-disable is blocked before issuing a request', async () => {
const { session } = setup(async url => {
assert.equal(url, '/api/v1/login')
return response({ token: 'fictional-admin-token', user: admin })
})
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.updateAccount(1, { disabled: true }), /不能停用当前管理员/)
})
test('logout at the response-to-store handoff cannot restore account data', async () => {
const { session } = setup(async url => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
return { ok: true, status: 200, json: async () => {
queueMicrotask(() => queueMicrotask(() => session.clear()))
return { code: 200, data: { items: [learner] } }
} }
})
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.loadAccounts(), /会话已变化/)
assert.deepEqual(session.state.accounts, [])
})
test('administrator password reset is blocked before issuing a write', async () => {
const { session } = setup(async url => {
assert.equal(url, '/api/v1/login')
return response({ token: 'fictional-admin-token', user: admin })
})
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.updateAccount(1, { password: 'fictional-new-password' }), /管理员/)
})
test('another administrator cannot be enabled, disabled, or reset from account management', async () => {
const otherAdmin = { id: 3, username: 'fixture.otheradmin', role: 'admin', disabled: false }
const { session } = setup(async url => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
assert.equal(url, '/api/v1/accounts')
return response({ items: [otherAdmin] })
})
await session.login('fixture.admin', 'fictional-password')
await session.loadAccounts()
for (const patch of [{ disabled: true }, { disabled: false }, { password: 'fictional-new-password' }]) {
await assert.rejects(session.updateAccount(3, patch), /管理员/)
}
})
test('logout HTTP 503 clears local private state but reports unconfirmed server revocation', async () => {
const { session, data } = setup(async url => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
if (url.endsWith('/accounts')) return response({ items: [learner] })
return response(null, 503)
})
await session.login('fixture.admin', 'fictional-password')
await session.loadAccounts()
await assert.rejects(session.logout(), /服务器尚未确认退出/)
assert.equal(session.state.user, null)
assert.deepEqual(session.state.accounts, [])
assert.equal(data.size, 0)
})
test('account switch stops on unconfirmed revocation of the previous session', async () => {
let loginCount = 0
const { session, data } = setup(async url => {
if (url.endsWith('/login')) {
loginCount++
return response({ token: 'fictional-admin-token', user: admin })
}
return response(null, 503)
})
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.login('fixture.otheradmin', 'fictional-password'), /服务器尚未确认退出/)
assert.equal(loginCount, 1)
assert.equal(session.state.user, null)
assert.equal(data.size, 0)
})
test('logout HTTP 401 is accepted because the server session is already invalid', async () => {
const { session } = setup(async url => url.endsWith('/login')
? response({ token: 'fictional-admin-token', user: admin })
: response(null, 401))
await session.login('fixture.admin', 'fictional-password')
await session.logout()
assert.equal(session.state.user, null)
})
test('rejected learner login reports failed server revocation without storing credentials', async () => {
const { session, data } = setup(async url => url.endsWith('/login')
? response({ token: 'fictional-learner-token', user: learner })
: response(null, 503))
await assert.rejects(session.login('fixture.learner', 'fictional-password'), /没有管理权限.*服务器尚未确认退出/)
assert.equal(session.state.user, null)
assert.equal(data.size, 0)
})
+24
View File
@@ -0,0 +1,24 @@
{
"upstream": "https://github.com/go-admin-team/go-admin-ui.git",
"commit": "67d393d713877572fab0b897296a4c1d525fc81d",
"license": "MIT (LICENSE)",
"unchangedFiles": [
"LICENSE",
"src/layout/BasicLayout.vue",
"src/components/Pagination/index.vue",
"src/components/Hamburger/index.vue",
"src/styles/variables.scss",
"src/styles/sidebar.scss",
"src/styles/mixin.scss",
"src/utils/scroll-to.js"
],
"adaptedFiles": {
"src/layout/index.vue": "Retains app-wrapper/sidebar/main-container shell, responsive mixin and styles; removes settings/tags/permissions modules.",
"src/layout/components/AppMain.vue": "Retains app-main section and background; removes keep-alive to avoid private-state retention.",
"src/views/Accounts.vue": "Curated sys-user/index.vue BasicLayout/card/table/pagination/dialog form structure; removes departments, PII, delete and legacy permissions.",
"src/views/Login.vue": "Curated login form fields and submit structure; removes default credentials, registration, captcha and promotional content.",
"src/layout/mixin/ResizeHandler.js": "Retained responsive behavior; removed unused route watcher argument for lint."
},
"dependencyChanges": "Retains Vue CLI, Vuex, Vue Router, Element Plus and source version ranges. Removes unused demo, chart, editor, upload, export, legacy Jest and Unix-only script dependencies. Uses native Node tests. New lockfile resolved for curated package only.",
"excluded": "Source env/config files, Git metadata, node_modules, running data and unrelated admin modules not copied."
}
+1
View File
@@ -0,0 +1 @@
module.exports = { publicPath: '/', productionSourceMap: false, devServer: { proxy: { '/api': { target: 'http://127.0.0.1:8000' } } } }
+17 -6
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.-
wiki_revision: 5b119f72802ffb7a58222e04a2b887925e50ce2e
synchronized_at: 2026-09-10T09:11:19Z
wiki_revision: 80cd91be7e64552b0205f45c59a2234934760b0e
synchronized_at: 2026-09-10T09:38:10Z
<!-- gitea-wiki-mirror:end -->
# LexGo 项目档案
@@ -29,7 +29,7 @@ LinguaCafe 参考基线由现有调研记录为 `c1ea298ce40c65b9dd33e9b26fd2e52
## 子项目与交付单元
当前独立交付物只有文档和治理工具。目标按一个产品版本、一个仓库规划:基于 go-admin 扩展的共享 Go API/Worker、独立学习前端和独立管理前端;内部 NLP 服务暂为建议。学习端与管理端分开构建,共用后端与账号体系。暂不创建不存在的产品目录或子项目规则。
当前独立交付物包括文档、治理工具、账号后端和两个前端。目标按一个产品版本、一个仓库规划:基于 go-admin 扩展的共享 Go API/Worker、独立学习前端和独立管理前端;内部 NLP 服务暂为建议。学习端与管理端分开构建,共用后端与账号体系。当前目录为 server、admin、learner 和 scripts,实际入口见代码地图。
## 技术栈与运行环境
@@ -80,7 +80,7 @@ LinguaCafe 参考基线由现有调研记录为 `c1ea298ce40c65b9dd33e9b26fd2e52
M0 在 LexGo 隔离工程中验证:指定前后端配套构建;MySQL 8 迁移与启动;登录、菜单和 API 权限;普通学习者的数据所有权隔离;用户区与管理区的身份衔接。管理 UI 的 dev/build 脚本使用类 Unix 的内联环境变量写法,Windows 执行方式需验证并最小适配,不假定 PowerShell 下直接成功。
复用范围为通用管理界面及其配套 API/身份能力;阅读器、词汇、短语与复习仍需实现 LexGo 领域逻辑。学习端另建 Vue 3 + TypeScript + Vite 工程,管理端继续使用其现有 Vue CLI/Vuex 结构;不要求两端统一构建工具和状态库。实际引入前核对代码和资源许可、排除本地配置及数据库等运行文件;按固定提交建立上游差异记录,后续升级先验证再更新基线。当前只完成选型记录,未宣称管理端已接入 LexGo。
复用范围为通用管理界面及其配套 API/身份能力;阅读器、词汇、短语与复习仍需实现 LexGo 领域逻辑。学习端另建 Vue 3 + TypeScript + Vite 工程,管理端继续使用其现有 Vue CLI/Vuex 结构;不要求两端统一构建工具和状态库。实际引入前核对代码和资源许可、排除本地配置及数据库等运行文件;按固定提交建立上游差异记录,后续升级先验证再更新基线。管理端已按选用模块接入 LexGo,范围与差异见本页末尾。
## 学习端技术方案(2026-09-10)
@@ -106,7 +106,7 @@ M0 在 LexGo 隔离工程中验证:指定前后端配套构建;MySQL 8 迁
## MVP 功能范围确认
2026-09-10 用户确认 F01~F12 全部进入首版,X01~X14 暂不纳入;随后授权使用 Quant-UX 制作原型,并同意先建原型工单,原型审核后再拆实施工单。桌面和手机均覆盖;默认学习语言已确认英语。产品代码未开始,具体交互通过原型审核固定。
2026-09-10 用户确认 F01~F12 全部进入首版,X01~X14 暂不纳入;随后授权使用 Quant-UX 制作原型,并同意先建原型工单,原型审核后再拆实施工单。桌面和手机均覆盖;默认学习语言已确认英语。工程基础已实现待验收,其余范围沿用已验收原型。
原型站点为 https://qux.ilapage.cn/;v1 编辑源和预览统一见需求总览及工单 #1。用户要求减少页面说明、按目标页面呈现。账号配置不进入仓库。
@@ -119,4 +119,15 @@ M0 在 LexGo 隔离工程中验证:指定前后端配套构建;MySQL 8 迁
学习端与管理端均使用账号(用户名)+密码登录,账号不要求邮箱格式;邮箱不作为必填登录标识。两端共用账号体系,管理权限由服务端单独校验。用户名唯一性和大小写规则在账号工单中细化。
[四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16) 已建立四阶段、14 张单元工单;详见需求总览与工作量估算。产品实现尚未开始。
[四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16) 已建立四阶段、14 张单元工单;详见需求总览与工作量估算。工程基础 #2 已实现并完成本地验证,等待用户验收:两端用户名登录、管理员创建/启停/重置学习账号、可撤销会话和本人英语空空间。阅读、导入、词典和复习尚未实现。代码在 feat/2-account-foundation 分支。
## 工程基础实际基线(#2,2026-09-10)
MySQL 服务端实际为 8.4.3,连接本机 127.0.0.1:3308;项目专用库 lexgo_dev、lexgo_test_issue2。连接凭据只存本机忽略的 .env.local;文档和 Git 不保存密码。已有非项目数据库未连接读写业务数据、未迁移。
Go 1.26.5 通过 Go 独立工具链下载并校验,系统 Go 1.24.0 保持不变;脚本只为子进程设置 GOTOOLCHAIN/GOSUMDB。Node 22.22.1,两个前端锁定 pnpm 9.15.1。管理端保留 Vue CLI/Vuex,学习端由官方 create-vue 3.23.0 生成,保留 Vue/TypeScript/Vite/Router/Pinia/Element Plus,实际包版本以各自 pnpm-lock.yaml 为准,不要求两端依赖版本一致。
server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必要 common/models 和 MIT 许可,新增 LexGo 路由、账号服务、会话与显式迁移;未接入整套后台路由、代码生成或任意任务执行。server/upstream.json 记录来源和文件摘要。admin/upstream.json 区分原样复用与适配的 go-admin-ui 布局、组件、页面和依赖,原始三个 go-admin 源工作区保持只读。
当前采用随机 Bearer 会话而非直接启用上游开发模式的超长 JWT。已验证退出、禁用、密码重置和到期拒绝;该差异是为满足当前可撤销会话要求,不宣称上游默认实现满足此要求。
+38 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
wiki_revision: 1c6333435f539af5253b18c563bce56bc336afc8
synchronized_at: 2026-09-10T09:11:22Z
wiki_revision: 51d40a6d4f7d0bcc0882bcd7d9c544aae964afa8
synchronized_at: 2026-09-10T09:38:13Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -43,7 +43,7 @@ Go 承担业务与后台任务,浏览器提供阅读学习界面,NLP 保留
管理端采用用户指定的 `D:/github_project/goadmin`,前后端固定 commit 见项目档案。后端现有入口为源仓库 main.go,业务分布于 app/cmd/common;管理前端入口为 go-admin-ui/src/main.js,页面位于 src/views,路由/API 封装位于 src/router、src/api。这些是外部参考仓库路径,尚未复制或运行于 LexGo。
M0 验证配套后再确定 LexGo 内部目录和身份衔接契约;通用管理能力可复用,用户与语言的数据所有权检查由业务层实现。既有分析中 gin-vue-admin 的推荐已被本次用户决策替代。
当前 #2 已固定首批 LexGo 目录和身份衔接契约;通用管理能力可复用,用户与语言的数据所有权检查由业务层实现。既有分析中 gin-vue-admin 的推荐已被本次用户决策替代。
## 学习端与管理端的目标架构
@@ -87,3 +87,38 @@ flowchart TD
源码检查发现 common/actions/permission.go 允许关闭 EnableDP,且 DataScope 默认分支不添加过滤;该机制用于管理数据范围,不能单独保证私人学习数据隔离。学习接口必须强制按认证用户归属查询,不允许客户端指定用户替代认证身份。
仅当管理流程引入多步安装、处理中/失败重试等明显交互不确定性时,补关键状态原型。通常的列表、编辑、删除确认沿用 go-admin 表格、表单和弹窗,记录字段、权限、异常和验收即可。
## 已实现工程入口(#2)
| 路径 | 职责 |
|---|---|
| server/cmd/lexgo/main.go | 配置连接、migrate/bootstrap/serve 命令;默认只监听 127.0.0.1:8000 |
| server/app/admin/models、server/common/models | 按 upstream.json 原样选用的 go-admin 模型和约定 |
| server/app/lexgo/database.go | MySQL 8 显式版本迁移、所有权标记、启动检查 |
| server/app/lexgo/service.go | 用户名校验、账号创建、bcrypt、随机会话摘要和撤销 |
| server/app/lexgo/router.go | JSON 请求、角色和归属授权、API 路由和安全错误响应 |
| server/app/lexgo/*_test.go | 真实 MySQL 隔离、撤销、并发创建、迁移拒绝/恢复、bootstrap 验证 |
| admin/src/views、admin/src/session.mjs | 基于 go-admin-ui 的账号页面与管理端会话 |
| learner/src/views、learner/src/stores/session.ts | 独立学习端登录与私人空空间、会话及迟到响应隔离 |
| scripts/server.py | 安全读取本机 .env.local,传入子进程;不输出秘密 |
### 账号 API v1
请求使用 JSON;受保护接口使用 Authorization: Bearer。成功结构 {code:200,data:...},创建账号 HTTP 201;失败使用实际 HTTP 400/401/403/404/409/429/500 和 {code:状态码,msg:必要提示}。响应 Cache-Control:no-store。
| 方法与路径 | 行为和权限 |
|---|---|
| POST /api/v1/login | username/password,返回 token、expiresAt、user;不要求邮箱 |
| GET /api/v1/me | 当前登录者 id/username/role/disabled |
| POST /api/v1/logout | 撤销当前会话 |
| GET /api/v1/space | 当前用户英语空间;拒绝查询参数指定用户 |
| GET /api/v1/spaces/:id | 仅本人的空间可读,他人编号返回 404 |
| GET /api/v1/accounts | 管理员查看账号列表;只输出必要字段 |
| POST /api/v1/accounts | 管理员创建 learner;拒绝客户端 role 等未知字段 |
| PATCH /api/v1/accounts/:id | 管理员启停或重置 learner 密码;不能修改自己或其他管理员 |
| GET /healthz | 检查数据库连接,仅返回健康状态 |
schema v1:sys_user 保留选用 go-admin 模型字段,唯一小写用户名;lexgo_spaces 以 owner_id 为主键;lexgo_sessions 保存 token_hash/owner_id/expires_at;lexgo_schema 记录版本与产品所有权。服务启动不自动迁移。迁移仅接受空库或合法已有 LexGo marker,拒绝空 marker、其他产品、负版本与未来版本;版本 0 可重试部分迁移,版本 1 幂等。
凭据字段只存在本地环境和必要数据库哈希中。后台账号密码更新使用表/字段更新,避免上游 BeforeUpdate hook 对已有哈希再次加密。账号行再会话行的锁顺序用于串行化撤销与请求;API 在事务提交后才返回成功。
+25 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
wiki_revision: 2230cceecec064fdfa4116cb683cc02c16d29dbe
synchronized_at: 2026-09-10T09:11:23Z
wiki_revision: c6044b9b3f863c90363563e079dff3e3fcb89bd8
synchronized_at: 2026-09-10T12:13:06Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -48,3 +48,26 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则
学习端与管理端均使用账号(用户名)+密码登录,账号不要求邮箱格式;邮箱不作为必填登录标识。两端共用账号体系,管理权限由服务端单独校验。用户名唯一性和大小写规则在账号工单中细化。
私人学习记录必须由服务端强制按当前用户归属过滤;管理员角色、部门范围或关闭 go-admin 数据权限开关均不能自动解除学习接口的本人归属约束。共享词典和个人释义分开维护。
## 账号与会话实现规则(#2)
- 账号去首尾空白并转小写,3~32 位、ASCII 字母开头,可含数字、点、下划线和连字符;唯一约束阻止大小写和并发重复。密码 10~72 字节,使用 bcrypt,不填写必需邮箱。
- 初始管理员只通过显式 bootstrap 建立;库中已有账号时拒绝再次 bootstrap,不覆盖密码。管理页面只创建学习者,不能将输入角色升级为管理员;本阶段不提供账号删除。
- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,8 小时过期。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。
- 每次请求读取账号当前状态与会话有效期;学习数据由认证身份确定,管理员权限不能解除学习接口的本人过滤。
- 两端分别使用 lexgo-admin-token、lexgo-learner-token 的 sessionStorage;401、退出和账号切换清除私人状态,以代次检查阻止迟到响应恢复旧数据。管理端拒绝学习者并撤销其新会话;撤销服务异常会提示未确认,不冒充服务端退出成功。
- 新建账号拥有英语空空间。此处没有书籍、阅读、词典或复习功能,不把空页面视为学习业务已实现。
## 已有账号登录与新密码规则
登录仅校验用户名格式及密码非空、不超过 bcrypt 的 72 字节上限,随后校验数据库中已有的密码哈希;不再用新建账号的密码长度下限拒绝已有凭据。错误密码继续返回 401,空密码、超长密码和非法用户名返回 400。登录限流、bcrypt 校验、禁用账号和会话撤销机制不变。
管理端新增学习账号与重置密码均要求 6~72 字节;首次 bootstrap 仍要求 10~72 字节。用户明确要求的本机指定账号凭据由管理员维护事务设置,不构成通用创建规则变更;密码更新须同时撤销该账号旧会话。密码值只能保存在本机安全配置中。
用户随后确认:管理端新增学习账号的密码下限调整为 6 个 UTF-8 字节、上限仍为 72 字节。POST /accounts 与新增表单同时执行此规则;5 字节及 73 字节拒绝,6 字节与 72 字节允许。中文等字符按 UTF-8 字节数计算。重置弹窗现也显示并执行 6~72 字节;规则调整不自动修改已有账号密码。
用户进一步确认重置密码下限也为 6 字节。新增和重置共用同一密码校验,前后端均为 6~72 UTF-8 字节;重置密码事务仍撤销该用户全部旧会话。首次管理员 bootstrap 的 10 字节下限保持原样。
+91 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
wiki_revision: 71ede783b059584cdfd7e5b8c3076def704407f6
synchronized_at: 2026-09-10T06:50:01Z
wiki_revision: a12e404e317f2f106936b99147f993d7cc6da608
synchronized_at: 2026-09-10T12:13:08Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -87,3 +87,92 @@ rg -n -g '*.md' 'PowerShell' docs
学习端拟使用 create-vue + Vite,测试组合为 Vitest + Playwright;管理端保持指定 go-admin-ui 的现有工具链。当前未创建学习端工程,因此尚无可执行的产品 npm/pnpm 脚本。本轮技术方案记录不改变该事实。
正式工程建立时记录精确 Node/包管理器/依赖版本、锁文件、构建和测试命令;分别验证两端构建、共享登录/API 授权,以及阅读选择、Unicode 定位和学习闭环。具体测试范围依赖后续 MVP,不提前声称全部业务场景已有覆盖。
## 工程基础运行与验证(#2)
已验证主机为 Windows PowerShell;Node 22.22.1、pnpm 9.15.1、Go 1.26.5、MySQL 8.4.3。默认本地入口:学习端 http://127.0.0.1:5173、管理端 http://127.0.0.1:5174、API http://127.0.0.1:8000。前端 /api 由开发代理转发到后端,不开启宽泛跨域。
1. 在 MySQL 8 中准备独立空库 lexgo_dev 与 lexgo_test_issue2(utf8mb4);不要把既有业务库用作测试库。
2. 复制根 .env.example 为 .env.local,填写本机 DB 凭据与初始管理员密码;.env.local 已被 Git 忽略。优先读取进程中已有 LEXGO_* 配置,文件仅补缺项。
3. 在仓库根执行以下后端命令。migrate 显式建表,bootstrap 仅首次执行,serve 不修改结构。
```powershell
python scripts/server.py migrate
python scripts/server.py bootstrap
python scripts/server.py serve
```
分别开终端启动两个前端:
```powershell
npx --yes pnpm@9.15.1 --dir admin install --frozen-lockfile
npx --yes pnpm@9.15.1 --dir admin dev
```
```powershell
npx --yes pnpm@9.15.1 --dir learner install --frozen-lockfile
npx --yes pnpm@9.15.1 --dir learner dev
```
验证命令(仓库根执行):
```powershell
python scripts/server.py test-integration
python scripts/server.py build
npx --yes pnpm@9.15.1 --dir admin test
npx --yes pnpm@9.15.1 --dir admin lint
npx --yes pnpm@9.15.1 --dir admin build
npx --yes pnpm@9.15.1 --dir learner test:unit --run
npx --yes pnpm@9.15.1 --dir learner test:e2e
npx --yes pnpm@9.15.1 --dir learner build
```
test-integration 默认使用 lexgo_test_issue2,可由 LEXGO_TEST_DB_NAME/LEXGO_TEST_DSN 指定其他 lexgo_test_ 前缀专用库;拒绝其他名字。迁移测试创建随机同前缀空库并仅清理自己创建的库,因此测试账号需具备该测试实例中的建库权限。普通 test 命令在没有测试库设置时跳过 MySQL 用例,不能当作集成通过。
学习端 Playwright 用例使用已安装的 Chrome 和虚构 API 响应;真实 MySQL/API 的浏览器联测结果在工单记录,不能混为同一种测试。管理端会话测试使用 Node 内置 runner。新增功能需针对行为写测试,而不是只验证打包。
本次本机初始管理员名为 admin,密码由工具随机生成,保存在忽略的 .env.local 的 LEXGO_BOOTSTRAP_PASSWORD;虚构浏览器学习账号 learner_a/learner_b 的本机测试口令记录在忽略的 .local/browser-accounts.json。这些本机文件不随 Git 交付,其他环境应自行设置。
停止开发服务器使用对应终端 Ctrl+C。回退本单代码可切换前一提交,保留项目库;前一版本只有文档,不存在需要启动的旧产品。删除测试/开发库不是自动回退步骤;生产升级与完整备份恢复由交付工单 #15 另行验证。
## 本机 supervisor 启动实例(2026-09-10)
用户要求将两端开发服务接入 D:/supervisord。配置文件为 D:/supervisord/supervisord.conf,控制台为 http://127.0.0.1:9009。使用现有 supervisor 启动入口;三个实例随 supervisor 启动,并在意外退出后自动重启。这不代表已配置 Windows 开机启动,也不替代 #15 的生产部署。
| 实例 | 工作目录 | 页面地址 | 日志 |
|---|---|---|---|
| lexgo-learner | D:/opc_project/lexgo/learner | http://127.0.0.1:5173 | D:/supervisord/lexgo-learner_out.log、lexgo-learner_err.log |
| lexgo-admin | D:/opc_project/lexgo/admin | http://127.0.0.1:5174 | D:/supervisord/lexgo-admin_out.log、lexgo-admin_err.log |
两端通过 C:/nodejs/node.exe 直接调用已经安装的 Vite / Vue CLI,启动时不安装依赖。学习端附带 --strictPort,避免端口冲突时自动换端口。管理端实例单独设置 npm_config_manage_package_manager_versions=false,防止 Vue CLI 编译后的 pnpm --version 检查触发全局 pnpm 自动下载指定版本而卡住;没有修改全局 pnpm 设置或项目锁文件。
通过控制台对 lexgo-learner / lexgo-admin / lexgo-api 单独 Start、Stop、Restart。启用托管时不要再执行同端口的手动 dev 命令。更改配置前备份原文件;本次备份为 D:/supervisord/supervisord.conf.lexgo-20260910-193432.bak。该备份只在本机保存,不提交仓库。
当前使用的 Go supervisord v0.6.8 重载可添加、移除实例,但现有实例对象不重新读取 command/environment。修改已有实例配置时,先停止目标实例,临时移除该节并重载,再加入新节并重载;不要重启整个 supervisor。回退本次接入时仅停止和移除上述两个实例,再重载,不覆盖随后新增的其他配置。
用户随后确认将共用后端一起托管。新增 lexgo-api,工作目录 D:/opc_project/lexgo/server,命令 D:/opc_project/lexgo/server/lexgo.exe serve,监听 127.0.0.1:8000。日志为 D:/supervisord/lexgo-api_out.log、lexgo-api_err.log。已启用 autostart、autorestart,启动不执行迁移或 bootstrap。
supervisor 直接管理编译后的 Go 进程,运行时不调用 Python。数据库连接值从本机 .env.local 复制到该实例的 environment,仅保存在本机 supervisor 配置中,不包含 bootstrap 密码。今后数据库连接配置变化,需要同步更新两处本机配置并按下述方式重新创建实例;不要打印配置或提交 Git。API 仍依赖本机 MySQL。
修改后端源码后,从控制台停止 lexgo-api,在仓库根目录执行 python scripts/server.py build,再启动 lexgo-api。Windows 中运行的 exe 不能直接覆盖;如果构建失败,修复后重新构建成功再启动。Python 此时只是构建辅助脚本。手动 python scripts/server.py serve 仍可用于临时调试,但必须先停止托管实例,避免 8000 端口冲突。
后端交接验证:停止时 8000 端口释放,重新启动后 lexgo-api 为 Running、/healthz 返回 200,两端 /api/v1/me 均返回预期 401。原有 11 个实例(含两个前端)的 PID 与状态均未变化。
验证结果:两个实例均为 Running,两端首页 HTTP 200,未登录请求 /api/v1/me 经代理返回预期 HTTP 401;已有 9 个实例的状态和 PID 均未变化。已交接并停止之前的手动前端进程。
## 本机验收账号维护(2026-09-10)
用户指定的管理账号 admin 已更新密码,并创建普通学习账号 dev 及其独立英语空间。指定密码只保存在忽略的 .local/account-credentials.json;.env.local 的 LEXGO_BOOTSTRAP_PASSWORD 已同步管理员当前密码,但仍不可对已有数据库重复执行 bootstrap。此前 learner_a / learner_b 测试账号保持原样。
两个指定账号通过本机管理员事务设置密码哈希并撤销旧会话;这是已获用户授权的本地维护操作。普通管理页面新增账号和重置密码均要求 6~72 字节,登录则直接验证已有非空密码,不以新密码下限拒绝已有账号。
新增真实 MySQL 回归测试覆盖已有短密码登录、错误密码拒绝、创建/常规重置仍拒绝短密码;已观察测试先失败再通过,完整后端集成测试通过。重新编译并由 supervisor 启动 lexgo-api 后,admin 管理端登录和 dev 学习端登录均成功,dev 的 /accounts 返回 403、英语空间归属正确,退出后的 token 返回 401。
新增账号 6 字节密码验证:管理端会话测试 18 项及完整后端 MySQL 集成测试通过;新增覆盖 5/6/72/73 字节和多字节字符,创建成功后验证登录。管理端 lint、生产构建及后端构建通过,lexgo-api 已由 supervisor 重新启动。
重置密码下限已同步为 6 字节。管理端测试与真实 MySQL 集成测试覆盖 5/6/72/73 字节及多字节字符,验证新密码登录成功、旧密码和旧会话被拒绝。管理员页面的新增/重置提示均为 6–72 字节;本次不修改现有账号密码。
+20 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Troubleshooting
wiki_revision: c202a8adb9b720981761e07f52fb7c0033d6d7d2
synchronized_at: 2026-09-10T06:27:43Z
wiki_revision: d9d5da6c3644a3004a7d55c5e9beda7c3aeaa679
synchronized_at: 2026-09-10T11:37:58Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -22,3 +22,21 @@ synchronized_at: 2026-09-10T06:27:43Z
## 必须停止的情况
目标仓库身份不清时停止远端写入;页面无法回读、无 revision 或同步失败时停止产品编码。数据越权、破坏性操作超出授权时停止相关操作。独立文档整理可以继续。
## 工程基础常见问题(#2)
- Go 工具链提示 GOSUMDB=off:scripts/server.py 仅在子进程启用 sum.golang.org 校验并固定 go1.26.5,不修改全局 go env。原理见 https://go.dev/doc/toolchain 。
- Windows 执行上游类 Unix 环境变量脚本失败:使用本项目 admin/package.json 的跨平台脚本,保留 Vue CLI,不直接调用原始源码目录中的脚本。
- 本地接口受到系统代理影响:访问 loopback;PowerShell 验证可用 Invoke-WebRequest -NoProxy。测试仅对 loopback 追加 NO_PROXY,不关闭其他网络代理。
- 原生前端依赖下载停滞:本次安装指定官方 npm registry 后成功;锁文件已生成,不更改全局 registry。不要通过关闭包完整性检查解决安装问题。
- serve 报需先迁移:核对 LEXGO_DB_NAME,显式执行 migrate;未知产品或版本必须先诊断,不手工植入 marker 绕过保护。
- 登录失效:重新登录;账号停用或重置密码会撤销全部会话。用户名不是邮箱,区分格式错误与凭据失败。
- 管理端构建中的 Sass 旧 API 弃用和 vendor 大小警告来自当前选用工具链,不影响已验证构建;生产体积优化不作为本单完成条件。
## supervisor 中管理端编译完成但 HTTP 请求超时
本机首次接入时,Vue CLI 已打印 App running at,但子进程停在 pnpm --version,主进程同步等待版本检查,页面和 API 代理均超时。同一命令在普通终端可正常返回页面。已在 lexgo-admin 实例中设置 environment=npm_config_manage_package_manager_versions="false",阻止这次检查自动切换 pnpm 版本,重新创建实例后首页 200、API 鉴权 401 验证通过。该环境变量仅作用于此实例;项目依赖仍按锁文件和明确版本安装。
排查时同时查看 D:/supervisord/lexgo-admin_out.log、lexgo-admin_err.log,核对端口占用者及其子进程。Running 只说明进程存活,仍需检查首页和 /api/v1/me。具体启动与配置更新方式见 Local-Development-and-Verification。
+12 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
wiki_revision: 838c096a1ea3a1c030e857e5229dfe366ea3acba
synchronized_at: 2026-09-10T09:11:31Z
wiki_revision: e506edcf2487b3d0faf035bf0a6826332bccd994
synchronized_at: 2026-09-10T09:38:24Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -15,7 +15,7 @@ synchronized_at: 2026-09-10T09:11:31Z
## 事实来源边界
当前产品实现:无。用户已确认多账号、数据独立、自托管和 F01~F12;已建立原型工单 #1 及 Quant-UX 桌面/手机设计。下方 U/A/N 与 M0~M4 保留为研究索引,不能扩大已批准 MVP,也不能用原型完成度代替产品实现进度。
当前产品实现:账号与独立空间工程基础已实现待验收,其余学习业务待实施。用户已确认多账号、数据独立、自托管和 F01~F12;已建立原型工单 #1 及 Quant-UX 桌面/手机设计。下方 U/A/N 与 M0~M4 保留为研究索引,不能扩大已批准 MVP,也不能用原型完成度代替产品实现进度。
## 当前需求索引
@@ -156,7 +156,7 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁
| F01~F12 / X01~X14 | 用户已确认 F 全部进入,X 暂不纳入 |
| 默认学习语言 | 用户已确认英语;原型文章为虚构样例 |
| 交互和页面细节 | 当前 v1 已通过用户审核,见工单 #1 验收记录 |
| 产品实现与运行验收 | 未开始 |
| 产品实现与运行验收 | 工程基础 #2 已实现并本地验证,待用户验收;其余学习业务未实现 |
## Quant-UX 原型任务
@@ -193,7 +193,7 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁
学习端与管理端均使用账号(用户名)+密码登录,账号不要求邮箱格式;邮箱不作为必填登录标识。两端共用账号体系,管理权限由服务端单独校验。用户名唯一性和大小写规则在账号工单中细化。
[四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16) 汇总四阶段与依赖,各单均包含完整路径、参考模块、设计证据、验收和人日估算。产品实现尚未开始。
[四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16) 汇总四阶段与依赖,各单均包含完整路径、参考模块、设计证据、验收和人日估算。工程基础 #2 已实现并完成本地验证,等待用户验收:两端用户名登录、管理员创建/启停/重置学习账号、可撤销会话和本人英语空空间。阅读、导入、词典和复习尚未实现。代码在 feat/2-account-foundation 分支。
| 阶段 | 工单 | 覆盖 | 预计人日 |
|---|---|---|---:|
@@ -211,3 +211,10 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁
| 4 | [#13 完成章节并查看个人基础进度](https://git.ilapage.cn/OPC/lexgo/issues/13) | F11 | 2~3 |
| 4 | [#14 补齐桌面与手机学习体验、主题及键盘操作](https://git.ilapage.cn/OPC/lexgo/issues/14) | F12 收尾 | 3~5 |
| 4 | [#15 自托管试用交付:安装、完整恢复与两账号验收](https://git.ilapage.cn/OPC/lexgo/issues/15) | B04、F01–F12 集成验收 | 4~6 |
## 工程基础交付边界(#2)
工程基础 #2 已实现并完成本地验证,等待用户验收:两端用户名登录、管理员创建/启停/重置学习账号、可撤销会话和本人英语空空间。阅读、导入、词典和复习尚未实现。代码在 feat/2-account-foundation 分支。
用户名登录与账号管理覆盖 B01/B02 和 F12 的登录基础;所有 F 学习业务仍需按 #3~#15 验收。#2 的会话和权限测试不能证明尚未存在的书籍/词汇/复习权限已通过。
+7 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Workload-Estimate
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Workload-Estimate.-
wiki_revision: 012c30f72b07cc9ff583c357409513a5362ca80a
synchronized_at: 2026-09-10T09:11:37Z
wiki_revision: de8fcf81b07bd7e96df1c68e5a63914139c62b6c
synchronized_at: 2026-09-10T09:38:31Z
<!-- gitea-wiki-mirror:end -->
# LexGo MVP 工作量估算
@@ -147,3 +147,8 @@ W00 后续需验证双前端与共享后端的接口/身份衔接、学习端构
用户已确认 F01~F12,X 系列不进入本轮 MVP。当前先进行 Quant-UX 原型工单,审核交互后再细化实施工单及估算。此前 M2/M3 及完整 P0~P2 的总量不应作为该 MVP 的已批准排期;管理端仅覆盖需要新增或调整的基础能力。原型制作与原型验收是不同状态,不把演示完成折算为产品实现完成。
Quant-UX 桌面和手机原型 v1 已建立,待人工审核;这不计为产品实现完成。原型实际投入未计时,不回填虚构工时。实施包和人日区间在原型审核后重新拆解,历史全量估算仅供参考。
## 工程基础进展(#2)
账号与独立空间基础已实现并本地验证,待用户验收。原计划 #2 为 6~9 人日,这不是实际消耗;未记录人工有效工时,不按 AI 会话耗时反推。#2 验收后,原拆分中 #3~#15 的规划工作量合计 46~75 人日,仍需在英语资源/划词验证后重估。本页顶部 52~84 为本轮开始时的完整 MVP 基线,不重复计入新增工作。
+5 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
wiki_revision: f77415b6507f85cabef2e1ea808628129deee2b7
synchronized_at: 2026-09-10T09:11:18Z
wiki_revision: 5ff3348f14ba1ffa810be3996b9340526a689529
synchronized_at: 2026-09-10T09:38:08Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -17,7 +17,7 @@ synchronized_at: 2026-09-10T09:11:18Z
## 五分钟开始
项目目前只有需求与设计资料、治理工具,没有产品代码。当前原型已通过用户验收,已拆 MVP 实施工单并安排技术验证。数据库已确定 MySQL 8,管理端已确定采用指定本地 go-admin/go-admin-ui;不把候选技术建议当作已实现能力。
项目已有账号与独立空间工程基础代码,尚未实现阅读学习业务。当前原型已通过用户验收,已拆 MVP 实施工单并安排技术验证。数据库已确定 MySQL 8,管理端已确定采用指定本地 go-admin/go-admin-ui;不把候选技术建议当作已实现能力。
命令从仓库根目录执行:`python dev_scripts/harness.py sync --verify` 同步线上页面并校验结构,预期退出码 0;`python -m unittest discover -s tests -v` 验证治理工具,预期全部通过。产品启动命令尚不存在。
@@ -27,7 +27,7 @@ synchronized_at: 2026-09-10T09:11:18Z
## 事实来源
Wiki 保存长期规则,工单保存单次实施与验收,Git 保存源码和镜像。当前产品实现为零,已有调研不等于运行验收。数据库选择以用户确认的 MySQL 8 为准,历史 PostgreSQL 建议不再适用。
Wiki 保存长期规则,工单保存单次实施与验收,Git 保存源码和镜像。当前工程基础已实现待验收,已有调研不等于运行验收。数据库选择以用户确认的 MySQL 8 为准,历史 PostgreSQL 建议不再适用。
原有四份调研资料作为证据页面保留:[需求提取](https://git.ilapage.cn/OPC/lexgo/wiki/LinguaCafe-Requirements.-)、[Go 分析](https://git.ilapage.cn/OPC/lexgo/wiki/Go-Architecture-Analysis.-)、[另一份需求提取](https://git.ilapage.cn/OPC/lexgo/wiki/LinguaCafe-Requirements-Alternative.-)、[另一份 Go 分析](https://git.ilapage.cn/OPC/lexgo/wiki/Go-Analysis-Alternative.-)。其正文为既有调研记录,不将整份建议视为已批准方案。
@@ -43,7 +43,7 @@ F01~F12 已获用户确认,X 系列暂不纳入。Quant-UX 桌面与手机
原型制作进度与证据见[原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1)。
Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn/#/simulate.html?h=a2aa10as5WpKpxe9fjDXETLqmigWeGWqsSjGmNmERlfgTpjXambHTDwagN8C&s=true&log=false)、[手机预览](https://qux.ilapage.cn/#/simulate.html?h=a2aa10aFDLzpVy0xPE70ftx5adTPyjWy5bj6nhPk2kDkJEwtIf1GiZjqx1n6&s=true&log=false)。产品实现尚未开始。
Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn/#/simulate.html?h=a2aa10as5WpKpxe9fjDXETLqmigWeGWqsSjGmNmERlfgTpjXambHTDwagN8C&s=true&log=false)、[手机预览](https://qux.ilapage.cn/#/simulate.html?h=a2aa10aFDLzpVy0xPE70ftx5adTPyjWy5bj6nhPk2kDkJEwtIf1GiZjqx1n6&s=true&log=false)。工程基础 #2 已实现并完成本地验证,等待用户验收:两端用户名登录、管理员创建/启停/重置学习账号、可撤销会话和本人英语空空间。阅读、导入、词典和复习尚未实现。代码在 feat/2-account-foundation 分支。
## 当前实施入口
+42
View File
@@ -0,0 +1,42 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
test-results/
playwright-report/
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright"
]
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test'
test('username login, private empty space, logout and history protection', async ({ page }) => {
const user = { id: 42, username: 'fictional-reader', role: 'learner' }
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path.endsWith('/login')) {
expect(route.request().postDataJSON()).toEqual({ username: user.username, password: 'fictional-password' })
data = { token: 'fictional-session', user }
} else if (path.endsWith('/me')) data = user
else if (path.endsWith('/space')) data = { ownerId: user.id, language: 'en' }
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/')
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible()
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await expect(page.getByText('书库还是空的')).toBeVisible()
await expect(page.getByText(user.username, { exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await page.goto('/?history=1')
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible()
await page.goBack()
await expect(page.getByText(user.username, { exact: true })).toHaveCount(0)
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible()
})
test('invalid credentials stay on login with accessible feedback', async ({ page }) => {
await page.route('**/api/v1/login', route => route.fulfill({ status: 401, json: { code: 401, msg: '账号或密码错误' } }))
await page.goto('/login')
await page.getByLabel('账号').fill('fictional-reader')
await page.getByLabel('密码', { exact: true }).fill('fictional-wrong-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('alert')).toHaveText('账号或密码错误')
await expect(page.getByRole('heading', { name: '我的书库' })).toHaveCount(0)
})
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["./**/*"]
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#284c38">
<title>LexGo · 学习空间</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "learner",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"dependencies": {
"element-plus": "^2.14.5",
"pinia": "^4.0.2",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.3",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"jsdom": "^29.1.1",
"npm-run-all2": "^9.0.2",
"typescript": "~6.0.0",
"vite": "^8.1.5",
"vitest": "^4.1.10",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "22.22.1"
},
"packageManager": "pnpm@9.15.1"
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig, devices } from '@playwright/test'
// Local development traffic must bypass any workstation HTTP proxy.
process.env.NO_PROXY = [process.env.NO_PROXY, '127.0.0.1', 'localhost'].filter(Boolean).join(',')
export default defineConfig({
testDir: './e2e',
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
use: { baseURL: 'http://127.0.0.1:5173', headless: true, trace: 'off' },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' } }],
webServer: { command: 'npm run dev -- --host 127.0.0.1', url: 'http://127.0.0.1:5173', reuseExistingServer: !process.env.CI },
})
+2144
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { watch } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { useSessionStore } from './stores/session'
const session = useSessionStore()
const router = useRouter()
watch(() => session.user, user => {
if (!user && router.currentRoute.value.meta.private) void router.replace('/login')
})
</script>
<template><RouterView /></template>
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSessionStore, TOKEN_KEY } from '../stores/session'
// All accounts and tokens in these tests are deliberately fictitious.
const user = { id: 7, username: 'fictional-reader', role: 'learner' }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
const denied = () => new Response(JSON.stringify({ code: 401, msg: '账号或密码错误' }), { status: 401 })
const loginResult = () => ok({ token: 'fictional-token', expiresAt: '2030-01-01', user })
describe('learner session boundaries', () => {
beforeEach(() => {
sessionStorage.clear()
setActivePinia(createPinia())
vi.restoreAllMocks()
})
it('accepts a username without email format and stores only the opaque token', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(loginResult())
const store = useSessionStore()
await store.login('fictional-reader', 'fictional-password')
expect(JSON.parse(fetch.mock.calls[0]![1]!.body as string)).toEqual({ username: 'fictional-reader', password: 'fictional-password' })
expect(store.user).toEqual(user)
expect(sessionStorage.getItem(TOKEN_KEY)).toBe('fictional-token')
expect(sessionStorage.length).toBe(1)
})
it('failed account switch clears the previous private identity and space', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(loginResult()).mockResolvedValueOnce(ok({ ownerId: 7, language: 'en' })).mockResolvedValueOnce(denied())
const store = useSessionStore()
await store.login('fictional-reader', 'fictional-password')
await store.loadSpace()
await expect(store.login('fictional-other', 'incorrect')).rejects.toThrow('账号或密码错误')
expect(store.user).toBeNull()
expect(store.space).toBeNull()
expect(sessionStorage.getItem(TOKEN_KEY)).toBeNull()
})
it('validates a persisted session before exposing identity and clears it on 401', async () => {
sessionStorage.setItem(TOKEN_KEY, 'fictional-expired-token')
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(denied())
const store = useSessionStore()
expect(store.user).toBeNull()
await store.restore()
expect(store.user).toBeNull()
expect(sessionStorage.getItem(TOKEN_KEY)).toBeNull()
})
it('clears identity and space when a private request gets 401', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(loginResult()).mockResolvedValueOnce(denied())
const store = useSessionStore()
await store.login('fictional-reader', 'fictional-password')
await expect(store.loadSpace()).rejects.toThrow()
expect(store.user).toBeNull()
expect(store.space).toBeNull()
expect(sessionStorage.getItem(TOKEN_KEY)).toBeNull()
})
it('clears locally immediately even if server logout fails', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(loginResult()).mockRejectedValueOnce(new Error('offline'))
const store = useSessionStore()
await store.login('fictional-reader', 'fictional-password')
const logout = store.logout()
expect(store.user).toBeNull()
expect(store.space).toBeNull()
expect(sessionStorage.getItem(TOKEN_KEY)).toBeNull()
await expect(logout).rejects.toThrow()
})
it('does not resurrect private space from a response arriving after logout', async () => {
let finish!: (value: Response) => void
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(loginResult()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })).mockResolvedValueOnce(ok(null))
const store = useSessionStore()
await store.login('fictional-reader', 'fictional-password')
const request = store.loadSpace()
await store.logout()
finish(ok({ ownerId: 7, language: 'en' }))
await request
expect(store.space).toBeNull()
expect(store.user).toBeNull()
})
it('an old request 401 cannot clear a newly signed-in account', async () => {
let finish!: (value: Response) => void
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(loginResult()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })).mockResolvedValueOnce(ok({ token: 'fictional-new-token', user: { ...user, id: 9 } }))
const store = useSessionStore()
await store.login('fictional-reader', 'fictional-password')
const request = store.loadSpace()
await store.login('fictional-other', 'fictional-password')
finish(denied())
await expect(request).rejects.toThrow()
expect(store.user?.id).toBe(9)
})
it('a late login response cannot restore a session after logout', async () => {
let finish!: (value: Response) => void
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const store = useSessionStore()
const request = store.login('fictional-reader', 'fictional-password')
await store.logout()
finish(loginResult())
await request
expect(store.user).toBeNull()
expect(sessionStorage.getItem(TOKEN_KEY)).toBeNull()
})
it('does not expose a space whose owner differs from the authenticated account', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(loginResult()).mockResolvedValueOnce(ok({ ownerId: 99, language: 'en' }))
const store = useSessionStore()
await store.login('fictional-reader', 'fictional-password')
await expect(store.loadSpace()).rejects.toThrow()
expect(store.space).toBeNull()
})
})
+6
View File
@@ -0,0 +1,6 @@
<template>
<svg class="book-mark" viewBox="0 0 64 64" fill="none" aria-hidden="true">
<path d="M32 18C24 12 14 12 6 15v36c8-3 18-3 26 3 8-6 18-6 26-3V15c-8-3-18-3-26 3Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<path d="M32 18v36M15 24c4-1 7 0 10 1M15 32c4-1 7 0 10 1M40 25c3-1 6-2 10-1M40 33c3-1 6-2 10-1" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</template>
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import 'element-plus/es/components/button/style/css'
import 'element-plus/es/components/input/style/css'
import './style.css'
createApp(App).use(createPinia()).use(router).mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useSessionStore } from '../stores/session'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
{ path: '/', name: 'library', meta: { private: true }, component: () => import('../views/LibraryView.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach(async to => {
const session = useSessionStore()
await session.restore()
if (to.meta.private && !session.user) return '/login'
if (to.name === 'login' && session.user) return '/'
})
export default router
+95
View File
@@ -0,0 +1,95 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const TOKEN_KEY = 'lexgo-learner-token'
interface User { id: number; username: string; role: 'admin' | 'learner' }
interface Space { ownerId: number; language: 'en' }
interface Login { token: string; expiresAt: string; user: User }
export const useSessionStore = defineStore('session', () => {
const user = ref<User | null>(null)
const space = ref<Space | null>(null)
const notice = ref('')
let token = sessionStorage.getItem(TOKEN_KEY) ?? ''
// Invalidate all in-flight work on any session boundary, including failed switches.
let generation = 0
let initialized = false
let restoring: Promise<void> | undefined
function clear() {
generation++
token = ''
user.value = null
space.value = null
sessionStorage.removeItem(TOKEN_KEY)
}
async function request<T>(path: string, method = 'GET', body?: unknown, auth = token, version = generation): Promise<T> {
const response = await fetch(`/api/v1/${path}`, {
method,
headers: { ...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store',
})
if (response.status === 401 && version === generation) {
clear()
notice.value = '登录已失效,请重新登录。'
}
const result = await response.json()
if (!response.ok || result.code !== 200) throw new Error(result.msg || '请求失败,请稍后重试。')
return result.data as T
}
async function login(username: string, password: string) {
clear()
initialized = true
notice.value = ''
const version = generation
const result = await request<Login>('login', 'POST', { username, password }, '', version)
if (version !== generation) return
sessionStorage.setItem(TOKEN_KEY, result.token)
token = result.token
user.value = result.user
}
async function restore() {
if (initialized) return
if (restoring) return restoring
const version = generation
restoring = (async () => {
try {
if (!token) return
const account = await request<User>('me')
if (version === generation) user.value = account
} catch {
if (version === generation) {
clear()
notice.value = '暂时无法恢复登录,请重新登录。'
}
} finally {
initialized = true
restoring = undefined
}
})()
return restoring
}
async function loadSpace() {
if (!user.value) throw new Error('请先登录。')
const version = generation
const ownerId = user.value.id
const result = await request<Space>('space')
if (version !== generation) return
if (result.ownerId !== ownerId || result.language !== 'en') throw new Error('学习空间暂时无法加载,请稍后重试。')
space.value = result
}
async function logout() {
const previousToken = token
clear()
notice.value = ''
if (previousToken) await request<null>('logout', 'POST', undefined, previousToken, -1)
}
return { user, space, notice, login, restore, logout, loadSpace }
})
+65
View File
@@ -0,0 +1,65 @@
:root {
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
color: #233d31;
background: #f7f5ee;
font-synthesis: none;
--el-color-primary: #315c43;
--el-color-primary-light-3: #597f66;
--el-color-primary-light-5: #8da493;
--el-color-primary-light-7: #bdccc0;
--el-color-primary-light-9: #eef2eb;
--el-color-primary-dark-2: #264a35;
--el-border-radius-base: 8px;
--el-font-size-base: 15px;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input { font: inherit; }
button:focus-visible, a:focus-visible { outline: 3px solid #bc803d; outline-offset: 4px; }
a { color: inherit; }
.brand { font-family: Georgia, serif; font-size: 30px; font-weight: 700; letter-spacing: -1px; text-decoration: none; }
.brand-dot { color: #ba8345; }
.login-page { min-height: 100dvh; display: grid; grid-template-columns: minmax(340px, 1fr) minmax(400px, 1fr); }
.welcome { background: #284c38; color: #f7f5ee; padding: 46px 10%; display: flex; flex-direction: column; justify-content: space-between; }
.welcome .brand { color: #f7f5ee; }
.welcome-copy { padding: 80px 0; max-width: 450px; }
.welcome-copy h2 { font-family: Georgia, 'Microsoft YaHei', serif; font-weight: 400; font-size: clamp(32px, 3.3vw, 52px); line-height: 1.55; letter-spacing: 2px; margin: 26px 0; }
.welcome-copy p { font-size: 16px; color: #d4dfd2; line-height: 1.9; }
.book-mark { width: 60px; height: 60px; color: #d6b980; }
.welcome-foot { font-size: 13px; letter-spacing: 2px; color: #c4d1c0; }
.login-main { display: grid; place-items: center; padding: 48px 28px; }
.login-form { width: min(100%, 360px); }
.eyebrow { color: #687568; font-size: 12px; letter-spacing: 3px; }
h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
.subtle { color: #748073; font-size: 14px; line-height: 1.8; }
.login-form form { margin-top: 36px; }
.field { display: block; margin-bottom: 22px; }
.field label { display: block; font-size: 14px; margin-bottom: 10px; }
.el-input { --el-input-height: 46px; --el-input-bg-color: #fffefa; --el-input-border-color: #d6dccf; }
.el-button { min-height: 42px; }
.login-submit { width: 100%; margin-top: 8px; height: 48px; letter-spacing: 4px; }
.notice { padding: 12px 15px; background: #fff0e7; border: 1px solid #ebc3a8; color: #8b4324; border-radius: 6px; font-size: 14px; line-height: 1.6; }
.site-header { padding: 24px max(24px, calc((100vw - 1200px) / 2)); border-bottom: 1px solid #e0e3d8; display: flex; align-items: center; gap: 32px; background: #fffdf7; }
.site-header nav { flex: 1; }
.active-nav { font-size: 15px; text-decoration: none; padding: 13px 0; border-bottom: 2px solid #315c43; }
.account { display: flex; align-items: center; gap: 18px; font-size: 14px; }
.account-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
.library { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
.library-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 38px; }
.language { border: 1px solid #d9decf; padding: 9px 17px; border-radius: 20px; font-size: 13px; background: #fffdf7; }
.empty-library { background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 44px 20px; text-align: center; }
.empty-library .book-mark { color: #6e8967; width: 56px; height: 56px; }
.empty-library h2 { font-weight: 500; font-size: 21px; margin: 26px 0 4px; }
.loading { padding: 80px 24px; text-align: center; color: #748073; }
@media (max-width: 760px) {
.login-page { grid-template-columns: 1fr; }
.welcome { padding: 28px; }
.welcome-copy, .welcome-foot { display: none; }
.login-main { padding: 50px 28px 70px; align-items: start; }
.site-header { padding: 20px; gap: 22px; flex-wrap: wrap; }
.account { margin-left: auto; gap: 10px; }
.account-name { max-width: 120px; }
.site-header nav { order: 3; flex-basis: 100%; padding-top: 8px; }
.library { margin-top: 32px; padding: 0 20px; }
h1 { font-size: 26px; }
}
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import BookMark from '../components/BookMark.vue'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const router = useRouter()
const loading = ref(true)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
try { await session.loadSpace() }
catch (reason) { error.value = reason instanceof Error ? reason.message : '暂时无法加载,请重试。' }
finally { loading.value = false }
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
onMounted(load)
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="library">
<div class="library-title"><div><h1>我的书库</h1><p class="subtle">你的阅读与学习,从这里开始。</p></div><span class="language">英语</span></div>
<p v-if="loading" role="status" class="loading">正在加载…</p>
<div v-else-if="error" class="notice"><p role="alert">{{ error }}</p><ElButton @click="load">重试</ElButton></div>
<section v-else-if="session.space" class="empty-library" aria-label="书库内容">
<BookMark /><h2>书库还是空的</h2><p class="subtle">这里将收纳你的阅读内容。</p>
</section>
</main>
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElInput } from 'element-plus'
import BookMark from '../components/BookMark.vue'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const router = useRouter()
const username = ref('')
const password = ref('')
const busy = ref(false)
const error = ref('')
async function submit() {
if (busy.value) return
error.value = ''
session.notice = ''
if (!username.value.trim() || !password.value) {
error.value = '请输入账号和密码。'
return
}
busy.value = true
try {
await session.login(username.value.trim(), password.value)
if (session.user) await router.replace('/')
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '登录失败,请稍后重试。'
session.notice = ''
} finally {
password.value = ''
busy.value = false
}
}
</script>
<template>
<div class="login-page">
<aside class="welcome">
<div class="brand">LexGo<span class="brand-dot">.</span></div>
<div class="welcome-copy">
<BookMark />
</div>
</aside>
<main class="login-main">
<div class="login-form">
<h1>欢迎回来</h1>
<p v-if="error || session.notice" role="alert" class="notice">{{ error || session.notice }}</p>
<form @submit.prevent="submit">
<div class="field">
<label for="username">账号</label>
<ElInput id="username" v-model="username" type="text" autocomplete="username" placeholder="请输入账号" :disabled="busy" />
</div>
<div class="field">
<label for="password">密码</label>
<ElInput id="password" v-model="password" type="password" autocomplete="current-password" placeholder="请输入密码" show-password :disabled="busy" />
</div>
<ElButton class="login-submit" type="primary" native-type="submit" :loading="busy">登录</ElButton>
</form>
</div>
</main>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+28
View File
@@ -0,0 +1,28 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
"allowImportingTsExtensions": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.app.json",
// Override to include only test files and clear exclusions.
// Application code imported in tests is automatically included via module resolution.
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
// Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly.
"lib": [],
"types": ["node", "jsdom"],
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
],
server: { proxy: { '/api': 'http://127.0.0.1:8000' } },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})
+14
View File
@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config.ts'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)
+39
View File
@@ -0,0 +1,39 @@
"""Run the LexGo backend with local environment settings without exposing secrets."""
from pathlib import Path
import os
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
def main():
commands = {
"migrate": ["go", "run", "./cmd/lexgo", "migrate"],
"bootstrap": ["go", "run", "./cmd/lexgo", "bootstrap"],
"serve": ["go", "run", "./cmd/lexgo", "serve"],
"build": ["go", "build", "./cmd/lexgo"],
"test": ["go", "test", "./...", "-count=1"],
"test-integration": ["go", "test", "./...", "-count=1", "-v"],
}
if len(sys.argv) != 2 or sys.argv[1] not in commands:
raise SystemExit("Usage: python scripts/server.py migrate|bootstrap|serve|build|test|test-integration")
env = dict(os.environ)
config = ROOT / ".env.local"
if config.exists():
for line in config.read_text(encoding="utf-8").splitlines():
if line.strip() and not line.lstrip().startswith("#") and "=" in line:
key, value = line.split("=", 1)
if key.startswith("LEXGO_"):
env.setdefault(key.strip(), value.strip())
env["GOTOOLCHAIN"] = "go1.26.5"
env["GOSUMDB"] = "sum.golang.org"
env["GIN_MODE"] = "release"
if sys.argv[1] == "test-integration":
env.setdefault("LEXGO_TEST_DB_NAME", "lexgo_test_issue2")
# Tests only use an explicitly supplied dedicated DSN; the Go tests enforce its prefix.
return subprocess.call(commands[sys.argv[1]], cwd=str(ROOT / "server"), env=env)
if __name__ == "__main__":
sys.exit(main())
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 go-admin-team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+33
View File
@@ -0,0 +1,33 @@
package models
import "go-admin/common/models"
type SysDept struct {
DeptId int `json:"deptId" gorm:"primaryKey;autoIncrement;"` //部门编码
ParentId int `json:"parentId" gorm:""` //上级部门
DeptPath string `json:"deptPath" gorm:"size:255;"` //
DeptName string `json:"deptName" gorm:"size:128;"` //部门名称
Sort int `json:"sort" gorm:"size:4;"` //排序
Leader string `json:"leader" gorm:"size:128;"` //负责人
Phone string `json:"phone" gorm:"size:11;"` //手机
Email string `json:"email" gorm:"size:64;"` //邮箱
Status int `json:"status" gorm:"size:4;"` //状态
models.ControlBy
models.ModelTime
DataScope string `json:"dataScope" gorm:"-"`
Params string `json:"params" gorm:"-"`
Children []SysDept `json:"children" gorm:"-"`
}
func (*SysDept) TableName() string {
return "sys_dept"
}
func (e *SysDept) Generate() models.ActiveRecord {
o := *e
return &o
}
func (e *SysDept) GetId() interface{} {
return e.DeptId
}
+77
View File
@@ -0,0 +1,77 @@
package models
import (
"go-admin/common/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type SysUser struct {
UserId int `gorm:"primaryKey;autoIncrement;comment:编码" json:"userId"`
Username string `json:"username" gorm:"size:64;comment:用户名"`
Password string `json:"-" gorm:"size:128;comment:密码"`
NickName string `json:"nickName" gorm:"size:128;comment:昵称"`
Phone string `json:"phone" gorm:"size:11;comment:手机号"`
RoleId int `json:"roleId" gorm:"size:20;comment:角色ID"`
Salt string `json:"-" gorm:"size:255;comment:加盐"`
Avatar string `json:"avatar" gorm:"size:255;comment:头像"`
Sex string `json:"sex" gorm:"size:255;comment:性别"`
Email string `json:"email" gorm:"size:128;comment:邮箱"`
DeptId int `json:"deptId" gorm:"size:20;comment:部门"`
PostId int `json:"postId" gorm:"size:20;comment:岗位"`
Remark string `json:"remark" gorm:"size:255;comment:备注"`
Status string `json:"status" gorm:"size:4;comment:状态"`
DeptIds []int `json:"deptIds" gorm:"-"`
PostIds []int `json:"postIds" gorm:"-"`
RoleIds []int `json:"roleIds" gorm:"-"`
Dept *SysDept `json:"dept"`
models.ControlBy
models.ModelTime
}
func (*SysUser) TableName() string {
return "sys_user"
}
func (e *SysUser) Generate() models.ActiveRecord {
o := *e
return &o
}
func (e *SysUser) GetId() interface{} {
return e.UserId
}
// Encrypt 加密
func (e *SysUser) Encrypt() (err error) {
if e.Password == "" {
return
}
var hash []byte
if hash, err = bcrypt.GenerateFromPassword([]byte(e.Password), bcrypt.DefaultCost); err != nil {
return
} else {
e.Password = string(hash)
return
}
}
func (e *SysUser) BeforeCreate(_ *gorm.DB) error {
return e.Encrypt()
}
func (e *SysUser) BeforeUpdate(_ *gorm.DB) error {
var err error
if e.Password != "" {
err = e.Encrypt()
}
return err
}
func (e *SysUser) AfterFind(_ *gorm.DB) error {
e.DeptIds = []int{e.DeptId}
e.PostIds = []int{e.PostId}
e.RoleIds = []int{e.RoleId}
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package lexgo
import (
"context"
"errors"
"fmt"
driver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"strings"
"time"
)
// Migrate takes a connection-scoped lock. Only an empty or LexGo-owned schema is accepted.
func Migrate(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
conn, err := sqlDB.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
var name, version string
if err = conn.QueryRowContext(ctx, "SELECT DATABASE(), VERSION()").Scan(&name, &version); err != nil {
return err
}
if name == "" || !strings.HasPrefix(version, "8.") {
return errors.New("a dedicated MySQL 8 database is required")
}
var locked int
lockName := "lexgo-migration:" + name
if err = conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,10)", lockName).Scan(&locked); err != nil || locked != 1 {
return errors.New("migration lock unavailable")
}
defer conn.ExecContext(context.Background(), "SELECT RELEASE_LOCK(?)", lockName)
var all, marker int
if err = conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=?", name).Scan(&all); err != nil {
return err
}
if err = conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=? AND table_name='lexgo_schema'", name).Scan(&marker); err != nil {
return err
}
if all > 0 && marker == 0 {
return errors.New("refusing to migrate an existing non-LexGo schema")
}
if all == 0 {
if _, err = conn.ExecContext(ctx, `CREATE TABLE lexgo_schema (id INT PRIMARY KEY, version INT NOT NULL, product VARCHAR(32) NOT NULL)`); err != nil {
return err
}
if _, err = conn.ExecContext(ctx, `INSERT INTO lexgo_schema (id,version,product) VALUES (1,0,'lexgo')`); err != nil {
return err
}
}
var current int
var product string
if err = conn.QueryRowContext(ctx, "SELECT version,product FROM lexgo_schema WHERE id=1").Scan(&current, &product); err != nil {
return err
}
if product != "lexgo" || current < 0 || current > 1 {
return errors.New("unknown schema version")
}
if current == 1 {
return nil
}
statements := []string{
`CREATE TABLE IF NOT EXISTS sys_user (
user_id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL, nick_name VARCHAR(128) NOT NULL DEFAULT '', phone VARCHAR(11) NOT NULL DEFAULT '',
role_id INT NOT NULL, salt VARCHAR(255) NOT NULL DEFAULT '', avatar VARCHAR(255) NOT NULL DEFAULT '',
sex VARCHAR(255) NOT NULL DEFAULT '', email VARCHAR(128) NOT NULL DEFAULT '', dept_id BIGINT NOT NULL DEFAULT 0,
post_id BIGINT NOT NULL DEFAULT 0, remark VARCHAR(255) NOT NULL DEFAULT '', status VARCHAR(4) NOT NULL DEFAULT '2',
create_by BIGINT NOT NULL DEFAULT 0, update_by BIGINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NULL, updated_at DATETIME(3) NULL, deleted_at DATETIME(3) NULL,
CHECK (role_id IN (1,2)), CHECK (status IN ('1','2'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS lexgo_spaces (
owner_id BIGINT PRIMARY KEY, language VARCHAR(16) NOT NULL DEFAULT 'en',
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS lexgo_sessions (
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
owner_id BIGINT NOT NULL, expires_at DATETIME(3) NOT NULL,
INDEX (owner_id), INDEX (expires_at),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
for i, s := range statements {
if _, err = conn.ExecContext(ctx, s); err != nil {
var sqlErr *driver.MySQLError
if errors.As(err, &sqlErr) {
return fmt.Errorf("migration statement %d failed (MySQL %d, SQLSTATE %s)", i+1, sqlErr.Number, sqlErr.SQLState)
}
return fmt.Errorf("migration statement %d failed", i+1)
}
}
_, err = conn.ExecContext(ctx, "UPDATE lexgo_schema SET version=1 WHERE id=1")
return err
}
func CheckSchema(db *gorm.DB) error {
var r struct {
Version int
Product string
}
if err := db.Table("lexgo_schema").Where("id=1").First(&r).Error; err != nil || r.Version != 1 || r.Product != "lexgo" {
return errors.New("run the explicit migration before starting")
}
return nil
}
+327
View File
@@ -0,0 +1,327 @@
package lexgo
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
driver "github.com/go-sql-driver/mysql"
admin "go-admin/app/admin/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const fixturePassword = "fixture-only-passphrase"
type envelope struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
}
func testDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("LEXGO_TEST_DSN")
if dsn == "" && os.Getenv("LEXGO_TEST_DB_NAME") != "" {
cfg := driver.NewConfig()
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(os.Getenv("LEXGO_DB_HOST"), os.Getenv("LEXGO_DB_PORT"))
cfg.User = os.Getenv("LEXGO_DB_USER")
cfg.Passwd = os.Getenv("LEXGO_DB_PASSWORD")
cfg.DBName = os.Getenv("LEXGO_TEST_DB_NAME")
cfg.ParseTime = true
cfg.Loc = time.UTC
dsn = cfg.FormatDSN()
}
if dsn == "" {
t.Skip("set LEXGO_TEST_DSN to a dedicated lexgo_test_ database")
}
t.Setenv("LEXGO_TEST_DSN", dsn)
cfg, err := driver.ParseDSN(dsn)
if err != nil || !strings.HasPrefix(cfg.DBName, "lexgo_test_") {
t.Fatal("requires dedicated lexgo_test_ database")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal("test database unavailable")
}
sqlDB, _ := db.DB()
t.Cleanup(func() { sqlDB.Close() })
if err := Migrate(db); err != nil {
t.Fatalf("migration: %v", err)
}
return db
}
func callAPI(t *testing.T, r *gin.Engine, method, path, token string, body any) (int, json.RawMessage) {
t.Helper()
b, _ := json.Marshal(body)
q := httptest.NewRequest(method, path, bytes.NewReader(b))
q.Header.Set("Content-Type", "application/json")
if token != "" {
q.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, q)
var e envelope
if err := json.Unmarshal(w.Body.Bytes(), &e); err != nil {
t.Fatalf("invalid JSON for %s %s (status %d)", method, path, w.Code)
}
return w.Code, e.Data
}
func loginToken(t *testing.T, r *gin.Engine, username, password string) string {
t.Helper()
code, data := callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": username, "password": password})
if code != 200 {
t.Fatalf("login status %d", code)
}
var result struct{ Token string }
json.Unmarshal(data, &result)
if result.Token == "" {
t.Fatal("missing token")
}
return result.Token
}
func randomName(prefix string) string {
b := make([]byte, 6)
rand.Read(b)
return prefix + hex.EncodeToString(b)
}
func TestMySQLLoginUsesStoredPasswordWithoutChangingCreationPolicy(t *testing.T) {
db := testDB(t)
// Fictional legacy credential: login verifies the stored hash independently
// of the policy applied when creating accounts or setting new passwords.
legacyPassword := strings.Repeat("z", 6)
u := admin.SysUser{Username: randomName("legacy"), Password: legacyPassword, RoleId: 1, Status: "2"}
if err := db.Create(&u).Error; err != nil {
t.Fatal("fixture creation failed")
}
r := Router(db, time.Now)
token := loginToken(t, r, u.Username, legacyPassword)
for _, password := range []string{strings.Repeat("y", 6), strings.Repeat("z", 5)} {
code, _ := callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": u.Username, "password": password})
if code != 401 {
t.Fatalf("incorrect stored password status %d, want 401", code)
}
}
code, _ := callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": randomName("new"), "password": strings.Repeat("z", 5)})
if code != 400 {
t.Fatalf("short new password status %d, want 400", code)
}
for _, password := range []string{strings.Repeat("z", 6), "虚构", strings.Repeat("z", 72)} {
name := randomName("six")
code, _ = callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": name, "password": password})
if code != 201 {
t.Fatalf("valid new password status %d, want 201", code)
}
loginToken(t, r, name, password)
}
code, _ = callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": randomName("long"), "password": strings.Repeat("z", 73)})
if code != 400 {
t.Fatalf("long new password status %d, want 400", code)
}
learner, err := createAccount(db, randomName("reset"), fixturePassword, 2, u.UserId)
if err != nil {
t.Fatal(err)
}
for _, password := range []string{strings.Repeat("z", 5), strings.Repeat("z", 73)} {
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", learner.ID), token, map[string]string{"password": password})
if code != 400 {
t.Fatalf("invalid reset password status %d, want 400", code)
}
}
oldPassword := fixturePassword
for _, password := range []string{legacyPassword, "虚构", strings.Repeat("z", 72)} {
oldToken := loginToken(t, r, learner.Username, oldPassword)
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", learner.ID), token, map[string]string{"password": password})
if code != 200 {
t.Fatalf("valid reset password status %d, want 200", code)
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", oldToken, nil)
if code != 401 {
t.Fatal("reset must revoke old session")
}
code, _ = callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": learner.Username, "password": oldPassword})
if code != 401 {
t.Fatal("reset must reject old password")
}
loginToken(t, r, learner.Username, password)
oldPassword = password
}
}
func TestMySQLAccountIsolationAndRevocation(t *testing.T) {
db := testDB(t)
if err := Migrate(db); err != nil {
t.Fatal("second migration must be idempotent")
}
var version string
db.Raw("SELECT VERSION()").Scan(&version)
if !strings.HasPrefix(version, "8.") {
t.Fatal("integration test requires MySQL 8")
}
a := admin.SysUser{Username: randomName("admin"), Password: fixturePassword, RoleId: 1, Status: "2"}
if err := db.Create(&a).Error; err != nil {
t.Fatal("fixture admin creation failed")
}
clock := time.Now().UTC()
r := Router(db, func() time.Time { return clock })
adminToken := loginToken(t, r, a.Username, fixturePassword)
users := []string{randomName("learner"), randomName("learner")}
ids := []int{}
for _, name := range users {
code, data := callAPI(t, r, "POST", "/api/v1/accounts", adminToken, map[string]string{"username": name, "password": fixturePassword})
if code != 201 {
t.Fatalf("create learner status %d", code)
}
var u struct {
ID int
Role string
}
json.Unmarshal(data, &u)
if u.ID == 0 || u.Role != "learner" {
t.Fatal("created user must be learner")
}
ids = append(ids, u.ID)
}
tokenA := loginToken(t, r, strings.ToUpper(users[0]), fixturePassword)
tokenB := loginToken(t, r, users[1], fixturePassword)
for i, token := range []string{tokenA, tokenB} {
code, data := callAPI(t, r, "GET", "/api/v1/space", token, nil)
var s struct {
OwnerID int `json:"ownerId"`
Language string
}
json.Unmarshal(data, &s)
if code != 200 || s.OwnerID != ids[i] || s.Language != "en" {
t.Fatal("space belongs to authenticated user")
}
code, _ = callAPI(t, r, "GET", fmt.Sprintf("/api/v1/spaces/%d", ids[1-i]), token, nil)
if code != 404 {
t.Fatalf("other space status %d, want 404", code)
}
code, _ = callAPI(t, r, "GET", "/api/v1/accounts", token, nil)
if code != 403 {
t.Fatal("learner reached management")
}
code, _ = callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": randomName("bad"), "password": fixturePassword})
if code != 403 {
t.Fatal("learner created account")
}
}
code, _ := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/space?ownerId=%d", ids[1]), tokenA, nil)
if code != 400 {
t.Fatal("owner override must be rejected")
}
code, _ = callAPI(t, r, "POST", "/api/v1/accounts", adminToken, map[string]string{"username": strings.ToUpper(users[0]), "password": fixturePassword})
if code != 409 {
t.Fatal("case duplicate must conflict")
}
code, _ = callAPI(t, r, "POST", "/api/v1/accounts", adminToken, map[string]string{"username": randomName("bad"), "password": fixturePassword, "role": "admin"})
if code != 400 {
t.Fatal("client role must be rejected")
}
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", a.UserId), adminToken, map[string]bool{"disabled": true})
if code != 400 {
t.Fatal("self disable must be rejected")
}
code, _ = callAPI(t, r, "POST", "/api/v1/logout", tokenA, nil)
if code != 200 {
t.Fatal("logout failed")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenA, nil)
if code != 401 {
t.Fatal("logged out token still valid")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil)
if code != 200 {
t.Fatal("A logout affected B")
}
tokenA = loginToken(t, r, users[0], fixturePassword)
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", ids[0]), adminToken, map[string]bool{"disabled": true})
if code != 200 {
t.Fatal("disable failed")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenA, nil)
if code != 401 {
t.Fatal("disabled session valid")
}
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", ids[0]), adminToken, map[string]bool{"disabled": false})
if code != 200 {
t.Fatal("enable failed")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenA, nil)
if code != 401 {
t.Fatal("reenable revived revoked session")
}
tokenA = loginToken(t, r, users[0], fixturePassword)
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", ids[0]), adminToken, map[string]string{"password": "replacement-fixture-pass"})
if code != 200 {
t.Fatal("reset failed")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenA, nil)
if code != 401 {
t.Fatal("reset session valid")
}
code, _ = callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": users[0], "password": fixturePassword})
if code != 401 {
t.Fatal("old password accepted")
}
loginToken(t, r, users[0], "replacement-fixture-pass")
clock = clock.Add(9 * time.Hour)
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil)
if code != 401 {
t.Fatal("expired session valid")
}
var count int64
db.Table("lexgo_sessions").Where("token_hash = ?", tokenB).Count(&count)
if count != 0 {
t.Fatal("raw token persisted")
}
}
func TestConcurrentDuplicateAccountHasOneWinner(t *testing.T) {
db := testDB(t)
a := admin.SysUser{Username: randomName("admin"), Password: fixturePassword, RoleId: 1, Status: "2"}
db.Create(&a)
r := Router(db, time.Now)
token := loginToken(t, r, a.Username, fixturePassword)
name := randomName("race")
var wg sync.WaitGroup
codes := make(chan int, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, _ := callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": name, "password": fixturePassword})
codes <- c
}()
}
wg.Wait()
close(codes)
wins, conflicts := 0, 0
for c := range codes {
if c == 201 {
wins++
}
if c == 409 {
conflicts++
}
}
if wins != 1 || conflicts != 1 {
t.Fatalf("wins=%d conflicts=%d", wins, conflicts)
}
}
+118
View File
@@ -0,0 +1,118 @@
package lexgo
import (
driver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"os"
"strings"
"testing"
"time"
)
func TestMigrationCanResumeOwnedPartialSchema(t *testing.T) {
db := emptyMigrationDB(t)
for _, query := range []string{
"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))",
"INSERT INTO lexgo_schema VALUES (1,0,'lexgo')",
"CREATE TABLE sys_user (user_id VARCHAR(32) PRIMARY KEY)",
} {
if err := db.Exec(query).Error; err != nil {
t.Fatal(err)
}
}
err := Migrate(db)
if err == nil || !strings.Contains(err.Error(), "MySQL") {
t.Fatal("DDL error must preserve a safe MySQL error code")
}
var version int
db.Raw("SELECT version FROM lexgo_schema WHERE id=1").Scan(&version)
if version != 0 {
t.Fatal("failed migration advanced version")
}
// This table belongs only to this test-created disposable schema.
if err = db.Exec("DROP TABLE sys_user").Error; err != nil {
t.Fatal(err)
}
if err = Migrate(db); err != nil {
t.Fatal(err)
}
if err = CheckSchema(db); err != nil {
t.Fatal(err)
}
}
func TestBootstrapDoesNotOverwriteExistingAccount(t *testing.T) {
db := emptyMigrationDB(t)
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := BootstrapAdmin(db, "admin", fixturePassword); err != nil {
t.Fatal(err)
}
if err := BootstrapAdmin(db, "another_admin", "different-fixture-password"); err == nil {
t.Fatal("bootstrap accepted non-empty accounts")
}
var count int64
db.Table("sys_user").Count(&count)
if count != 1 {
t.Fatal("bootstrap changed existing account set")
}
loginToken(t, Router(db, time.Now), "admin", fixturePassword)
}
func emptyMigrationDB(t *testing.T) *gorm.DB {
t.Helper()
base := testDB(t)
cfg, err := driver.ParseDSN(os.Getenv("LEXGO_TEST_DSN"))
if err != nil {
t.Fatal(err)
}
name := randomName("lexgo_test_migration_")
if err = base.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4").Error; err != nil {
t.Fatal("create isolated migration test schema failed")
}
t.Cleanup(func() {
if err := base.Exec("DROP DATABASE `" + name + "`").Error; err != nil {
t.Error("cleanup isolated migration schema failed")
}
})
cfg.DBName = name
db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal("connect migration test database failed")
}
pool, _ := db.DB()
t.Cleanup(func() { pool.Close() })
return db
}
func TestMigrationRefusesUnownedOrUnsupportedSchema(t *testing.T) {
for _, tc := range []struct{ name, marker string }{
{"empty_marker", ""},
{"negative_version", "INSERT INTO lexgo_schema VALUES (1,-1,'lexgo')"},
{"future_version", "INSERT INTO lexgo_schema VALUES (1,2,'lexgo')"},
{"wrong_product", "INSERT INTO lexgo_schema VALUES (1,0,'another-app')"},
} {
t.Run(tc.name, func(t *testing.T) {
db := emptyMigrationDB(t)
if err := db.Exec("CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))").Error; err != nil {
t.Fatal(err)
}
if tc.marker != "" {
if err := db.Exec(tc.marker).Error; err != nil {
t.Fatal(err)
}
}
if err := Migrate(db); err == nil {
t.Fatal("unsafe migration was accepted")
}
var count int64
db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='sys_user'").Scan(&count)
if count != 0 {
t.Fatal("rejected migration created a user table")
}
})
}
}
+200
View File
@@ -0,0 +1,200 @@
package lexgo
import (
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
r.SetTrustedProxies(nil)
r.Use(func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.Header("X-Content-Type-Options", "nosniff")
c.Next()
})
r.GET("/healthz", func(c *gin.Context) {
sqlDB, err := db.DB()
if err != nil || sqlDB.PingContext(c.Request.Context()) != nil {
respond(c, 503, nil, failure(503, "服务暂不可用"))
return
}
respond(c, 200, map[string]string{"status": "ok"}, nil)
})
v := r.Group("/api/v1")
limits := &loginLimits{entries: map[string]attempts{}}
v.POST("/login", func(c *gin.Context) {
var input struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decode(c, &input); err != nil {
respond(c, 400, nil, err)
return
}
name, err := loginCredentials(input.Username, input.Password)
if err != nil {
respond(c, 400, nil, err)
return
}
if !limits.allow(c.ClientIP(), now()) {
respond(c, 429, nil, failure(429, "登录尝试过多,请稍后再试"))
return
}
result, err := login(db.WithContext(c.Request.Context()), now(), name, input.Password)
respond(c, 200, result, err)
})
protect := func(adminOnly bool, action func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc {
return func(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if !ok {
respond(c, 401, nil, failure(401, "请先登录"))
return
}
var data any
err := db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
u, err := authenticate(tx, token, now())
if err != nil {
return err
}
if adminOnly && u.RoleId != 1 {
return failure(403, "需要管理员权限")
}
data, err = action(c, tx, u)
return err
})
status := 200
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/accounts" {
status = 201
}
respond(c, status, data, err)
}
}
v.GET("/me", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) { return account(u), nil }))
v.POST("/logout", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
return nil, tx.Where("token_hash = ? AND owner_id = ?", digest(token), u.UserId).Delete(&Session{}).Error
}))
space := protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if c.Request.URL.RawQuery != "" {
return nil, failure(400, "学习空间不接受用户参数")
}
id := u.UserId
if c.Param("id") != "" {
n, err := strconv.Atoi(c.Param("id"))
if err != nil || n != u.UserId {
return nil, failure(404, "空间不存在")
}
id = n
}
var result Space
err := tx.Where("owner_id = ? AND owner_id = ?", u.UserId, id).First(&result).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, failure(404, "空间不存在")
}
return result, err
})
v.GET("/space", space)
v.GET("/spaces/:id", space)
v.GET("/accounts", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var users []admin.SysUser
if err := tx.Order("user_id DESC").Find(&users).Error; err != nil {
return nil, err
}
items := make([]Account, 0, len(users))
for _, user := range users {
items = append(items, account(user))
}
return gin.H{"items": items}, nil
}))
v.POST("/accounts", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decode(c, &input); err != nil {
return nil, err
}
return createAccount(tx, input.Username, input.Password, 2, u.UserId)
}))
v.PATCH("/accounts/:id", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
return nil, failure(400, "账号编号无效")
}
var input UpdateAccount
if err = decode(c, &input); err != nil {
return nil, err
}
return updateAccount(tx, id, u.UserId, input)
}))
r.NoRoute(func(c *gin.Context) { respond(c, 404, nil, failure(404, "页面或接口不存在")) })
return r
}
func decode(c *gin.Context, value any) error {
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
return failure(400, "请使用 JSON 请求")
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16*1024)
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if d.Decode(value) != nil {
return failure(400, "请求内容无效")
}
if d.Decode(new(any)) != io.EOF {
return failure(400, "请求内容无效")
}
return nil
}
func respond(c *gin.Context, status int, data any, err error) {
if err != nil {
var e *apiError
if errors.As(err, &e) {
c.JSON(e.status, gin.H{"code": e.status, "msg": e.message})
return
}
c.JSON(500, gin.H{"code": 500, "msg": "服务暂不可用,请稍后再试"})
return
}
c.JSON(status, gin.H{"code": 200, "data": data})
}
type attempts struct {
count int
until time.Time
}
type loginLimits struct {
mu sync.Mutex
entries map[string]attempts
}
func (l *loginLimits) allow(ip string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
for k, v := range l.entries {
if !v.until.After(now) {
delete(l.entries, k)
}
}
v, exists := l.entries[ip]
if !exists {
if len(l.entries) >= 10000 {
return false
}
v = attempts{until: now.Add(time.Minute)}
}
v.count++
l.entries[ip] = v
return v.count <= 30
}
+27
View File
@@ -0,0 +1,27 @@
package lexgo
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestLoginRejectsEmailAndMissingPasswordBeforeDatabase(t *testing.T) {
r := Router(nil, time.Now)
for _, payload := range []string{
`{"username":"person@example.test","password":"example-password"}`,
`{"username":"learner","password":""}`,
`{"username":"learner","password":"` + strings.Repeat("x", 73) + `"}`,
} {
w := httptest.NewRecorder()
q := httptest.NewRequest(http.MethodPost, "/api/v1/login", bytes.NewBufferString(payload))
q.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, q)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid account input status = %d, want 400", w.Code)
}
}
}
+260
View File
@@ -0,0 +1,260 @@
package lexgo
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"regexp"
"strings"
"time"
driver "github.com/go-sql-driver/mysql"
admin "go-admin/app/admin/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Account struct {
ID int `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
}
func account(u admin.SysUser) Account {
role := "learner"
if u.RoleId == 1 {
role = "admin"
}
return Account{u.UserId, u.Username, role, u.Status != "2"}
}
type Space struct {
OwnerID int `json:"ownerId" gorm:"primaryKey"`
Language string `json:"language"`
}
func (Space) TableName() string { return "lexgo_spaces" }
type Session struct {
TokenHash string `gorm:"primaryKey"`
OwnerID int
ExpiresAt time.Time
}
func (Session) TableName() string { return "lexgo_sessions" }
type LoginResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
User Account `json:"user"`
}
type apiError struct {
status int
message string
}
func (e *apiError) Error() string { return e.message }
func failure(code int, msg string) error { return &apiError{code, msg} }
var usernamePattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{2,31}$`)
func credentials(username, password string) (string, error) {
name, err := loginCredentials(username, password)
if err != nil {
return "", err
}
if len(password) < 10 {
return "", failure(400, "密码须为 10~72 字节")
}
return name, nil
}
// Login verifies an existing hash; account creation and normal password resets
// separately enforce the current minimum password length.
func loginCredentials(username, password string) (string, error) {
username = strings.ToLower(strings.TrimSpace(username))
if !usernamePattern.MatchString(username) {
return "", failure(400, "账号须为字母开头的 3~32 位字母、数字、点、下划线或连字符")
}
if len(password) == 0 || len(password) > 72 {
return "", failure(400, "密码不能为空且不能超过 72 字节")
}
return username, nil
}
func digest(token string) string { v := sha256.Sum256([]byte(token)); return hex.EncodeToString(v[:]) }
func accountCredentials(username, password string) (string, error) {
name, err := loginCredentials(username, password)
if err != nil {
return "", err
}
if len(password) < 6 {
return "", failure(400, "密码须为 6~72 字节")
}
return name, nil
}
func createAccount(tx *gorm.DB, username, password string, role, actor int) (Account, error) {
name, err := accountCredentials(username, password)
if err != nil {
return Account{}, err
}
u := admin.SysUser{Username: name, Password: password, RoleId: role, Status: "2"}
u.CreateBy = actor
// Keep the upstream bcrypt BeforeCreate hook; never use BeforeUpdate for status changes.
if err = tx.Create(&u).Error; err != nil {
var dup *driver.MySQLError
if errors.As(err, &dup) && dup.Number == 1062 {
return Account{}, failure(409, "账号已存在")
}
return Account{}, err
}
if err = tx.Create(&Space{OwnerID: u.UserId, Language: "en"}).Error; err != nil {
return Account{}, err
}
return account(u), nil
}
// BootstrapAdmin only initializes a database with no accounts, under a database row lock.
func BootstrapAdmin(db *gorm.DB, username, password string) error {
if _, err := credentials(username, password); err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
var marker struct{ ID int }
if err := tx.Table("lexgo_schema").Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=1").First(&marker).Error; err != nil {
return err
}
var count int64
if err := tx.Table("sys_user").Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("bootstrap requires a database with no accounts; existing accounts were not changed")
}
_, err := createAccount(tx, username, password, 1, 0)
return err
})
}
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("non-account-timing-fixture"), bcrypt.DefaultCost)
func login(db *gorm.DB, now time.Time, name, password string) (LoginResult, error) {
var result LoginResult
err := db.Transaction(func(tx *gorm.DB) error {
var u admin.SysUser
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("username = ?", name).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
return failure(401, "账号或密码错误")
}
if err != nil {
return err
}
if bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil || u.Status != "2" {
return failure(401, "账号或密码错误")
}
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return err
}
token := hex.EncodeToString(b)
expiry := now.Add(8 * time.Hour)
if err = tx.Where("owner_id = ? AND expires_at <= ?", u.UserId, now).Delete(&Session{}).Error; err != nil {
return err
}
if err = tx.Create(&Session{digest(token), u.UserId, expiry}).Error; err != nil {
return err
}
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&Space{u.UserId, "en"}).Error; err != nil {
return err
}
result = LoginResult{token, expiry, account(u)}
return nil
})
return result, err
}
func authenticate(tx *gorm.DB, token string, now time.Time) (admin.SysUser, error) {
var u admin.SysUser
if len(token) != 64 {
return u, failure(401, "请重新登录")
}
var s Session
if err := tx.Where("token_hash = ?", digest(token)).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return u, failure(401, "请重新登录")
}
return u, err
}
// A consistent user-then-session lock order serializes disable/reset/logout with requests.
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, s.OwnerID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return u, failure(401, "请重新登录")
}
return u, err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ?", digest(token)).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return u, failure(401, "请重新登录")
}
return u, err
}
if u.Status != "2" || !s.ExpiresAt.After(now) {
return u, failure(401, "登录已失效,请重新登录")
}
return u, nil
}
type UpdateAccount struct {
Disabled *bool `json:"disabled"`
Password *string `json:"password"`
}
func updateAccount(tx *gorm.DB, id, actor int, input UpdateAccount) (Account, error) {
if id == actor {
return Account{}, failure(400, "不能在此修改自己的账号")
}
if input.Disabled == nil && input.Password == nil {
return Account{}, failure(400, "请选择要修改的内容")
}
var u admin.SysUser
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Account{}, failure(404, "账号不存在")
}
return Account{}, err
}
if u.RoleId != 2 {
return Account{}, failure(400, "此操作仅支持学习账号")
}
updates := map[string]any{"update_by": actor, "updated_at": time.Now().UTC()}
if input.Disabled != nil {
u.Status = "2"
if *input.Disabled {
u.Status = "1"
}
updates["status"] = u.Status
}
if input.Password != nil {
if _, err := accountCredentials(u.Username, *input.Password); err != nil {
return Account{}, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(*input.Password), bcrypt.DefaultCost)
if err != nil {
return Account{}, err
}
updates["password"] = string(hash)
}
// Table + map deliberately avoids the upstream unconditional password re-hashing hook.
if err := tx.Table("sys_user").Where("user_id = ?", id).Updates(updates).Error; err != nil {
return Account{}, err
}
if err := tx.Where("owner_id = ?", id).Delete(&Session{}).Error; err != nil {
return Account{}, err
}
return account(u), nil
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
driver "github.com/go-sql-driver/mysql"
"go-admin/app/lexgo"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func openDB() (*gorm.DB, error) {
c := driver.NewConfig()
c.Net = "tcp"
host := os.Getenv("LEXGO_DB_HOST")
if host == "" {
host = "127.0.0.1"
}
port := os.Getenv("LEXGO_DB_PORT")
if port == "" {
port = "3308"
}
c.Addr = net.JoinHostPort(host, port)
c.User = os.Getenv("LEXGO_DB_USER")
c.Passwd = os.Getenv("LEXGO_DB_PASSWORD")
c.DBName = os.Getenv("LEXGO_DB_NAME")
if c.User == "" || c.Passwd == "" || c.DBName == "" {
return nil, errors.New("set LEXGO_DB_USER, LEXGO_DB_PASSWORD and LEXGO_DB_NAME")
}
c.ParseTime = true
c.Loc = time.UTC
c.Timeout = 5 * time.Second
c.ReadTimeout = 10 * time.Second
c.WriteTimeout = 10 * time.Second
c.Params = map[string]string{"charset": "utf8mb4"}
db, err := gorm.Open(mysql.Open(c.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
return nil, errors.New("database connection failed; check local configuration")
}
pool, err := db.DB()
if err != nil {
return nil, err
}
pool.SetMaxOpenConns(10)
pool.SetMaxIdleConns(5)
pool.SetConnMaxLifetime(5 * time.Minute)
var version string
if err = db.Raw("SELECT VERSION()").Scan(&version).Error; err != nil || !strings.HasPrefix(version, "8.") {
pool.Close()
return nil, errors.New("MySQL 8 is required")
}
return db, nil
}
func run() error {
if len(os.Args) != 2 {
return errors.New("usage: lexgo migrate|bootstrap|serve")
}
command := os.Args[1]
if command != "migrate" && command != "bootstrap" && command != "serve" {
return errors.New("unknown command")
}
db, err := openDB()
if err != nil {
return err
}
pool, _ := db.DB()
defer pool.Close()
if command == "migrate" {
if err = lexgo.Migrate(db); err != nil {
return err
}
fmt.Println("LexGo schema version 1 ready")
return nil
}
if err = lexgo.CheckSchema(db); err != nil {
return err
}
if command == "bootstrap" {
if err = lexgo.BootstrapAdmin(db, os.Getenv("LEXGO_BOOTSTRAP_USERNAME"), os.Getenv("LEXGO_BOOTSTRAP_PASSWORD")); err != nil {
return err
}
fmt.Println("Initial administrator created; credentials are only in your local configuration")
return nil
}
gin.SetMode(gin.ReleaseMode)
addr := os.Getenv("LEXGO_LISTEN")
if addr == "" {
addr = "127.0.0.1:8000"
}
srv := &http.Server{Addr: addr, Handler: lexgo.Router(db, time.Now), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
done := make(chan error, 1)
go func() { done <- srv.ListenAndServe() }()
fmt.Println("LexGo API listening on", addr)
select {
case err = <-done:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdown)
}
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
+32
View File
@@ -0,0 +1,32 @@
package models
import (
"time"
"gorm.io/gorm"
)
type ControlBy struct {
CreateBy int `json:"createBy" gorm:"index;comment:创建者"`
UpdateBy int `json:"updateBy" gorm:"index;comment:更新者"`
}
// SetCreateBy 设置创建人id
func (e *ControlBy) SetCreateBy(createBy int) {
e.CreateBy = createBy
}
// SetUpdateBy 设置修改人id
func (e *ControlBy) SetUpdateBy(updateBy int) {
e.UpdateBy = updateBy
}
type Model struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
}
type ModelTime struct {
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;comment:删除时间"`
}
+12
View File
@@ -0,0 +1,12 @@
package models
import "time"
type Migration struct {
Version string `gorm:"primaryKey"`
ApplyTime time.Time `gorm:"autoCreateTime"`
}
func (Migration) TableName() string {
return "sys_migration"
}
+30
View File
@@ -0,0 +1,30 @@
package models
type Response struct {
// 代码
Code int `json:"code" example:"200"`
// 数据集
Data interface{} `json:"data"`
// 消息
Msg string `json:"msg"`
RequestId string `json:"requestId"`
}
type Page struct {
List interface{} `json:"list"`
Count int `json:"count"`
PageIndex int `json:"pageIndex"`
PageSize int `json:"pageSize"`
}
// ReturnOK 正常返回
func (res *Response) ReturnOK() *Response {
res.Code = 200
return res
}
// ReturnError 错误返回
func (res *Response) ReturnError(code int) *Response {
res.Code = code
return res
}
+11
View File
@@ -0,0 +1,11 @@
package models
import "gorm.io/gorm/schema"
type ActiveRecord interface {
schema.Tabler
SetCreateBy(createBy int)
SetUpdateBy(updateBy int)
Generate() ActiveRecord
GetId() interface{}
}
+45
View File
@@ -0,0 +1,45 @@
module go-admin
go 1.26.5
require (
github.com/gin-gonic/gin v1.12.0
github.com/go-sql-driver/mysql v1.9.3
golang.org/x/crypto v0.54.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
)
require (
filippo.io/edwards25519 v1.1.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/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // 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.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+105
View File
@@ -0,0 +1,105 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
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.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
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/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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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/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=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+35
View File
@@ -0,0 +1,35 @@
{
"repository": "https://github.com/go-admin-team/go-admin.git",
"commit": "f06540883b41d03782bb6b2c4150f298f328c6b6",
"selection": "account models and common model conventions; LexGo routes and revocable sessions are new",
"files": [
{
"path": "LICENSE.md",
"sha256": "e1bd2eb01e4ddae9a0634b9d529f3a572e4772c40eeac2d37e7f9b8b19e77572"
},
{
"path": "app/admin/models/sys_user.go",
"sha256": "7cf93b6d6d9793977ab39245493280c4309febe0af2769b084357851aec3ada7"
},
{
"path": "app/admin/models/sys_dept.go",
"sha256": "e665ed13fd504b9125b5348d05dcb65d82aba1a07ea43d5ab943193919d34411"
},
{
"path": "common/models/by.go",
"sha256": "3f811f7f522ac0bfd8b7b89637f6030012b7523cf47ddb0e476db7c1c8cc4381"
},
{
"path": "common/models/type.go",
"sha256": "82c0d25d4bcc3bee2e2a383b720db122c88f91f9f19c012683eaf377f773db93"
},
{
"path": "common/models/migrate.go",
"sha256": "89da220cfc5d08ec9d28d752224a18247eab8f562de47e8f4ee5ce16fa5c6e59"
},
{
"path": "common/models/response.go",
"sha256": "b0276538b77bf936c9aa57344f33292801a7e0cd6da2999d53a7255b7e7fcf01"
}
]
}