feat: 接入两端用户名登录和独立学习空间 (#2)
This commit is contained in:
@@ -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
|
||||
@@ -7,3 +7,6 @@ gitea.env
|
||||
node_modules/
|
||||
.venv/
|
||||
dist/
|
||||
.local/
|
||||
server/lexgo.exe
|
||||
server/lexgo
|
||||
|
||||
@@ -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;两端分别构建。
|
||||
|
||||
@@ -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)。凭据只使用安全配置或环境变量,不写入仓库。
|
||||
|
||||
@@ -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' } }
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env*
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = { presets: ['@vue/cli-plugin-babel/preset'] }
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Generated
+8346
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 >= 10 && 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('密码须为 10–72 字节')
|
||||
return request('/accounts', 'POST', { username: normalizeUsername(username), password })
|
||||
},
|
||||
async updateAccount(id, patch) {
|
||||
authorized()
|
||||
if (patch.password !== undefined && !validPassword(patch.password)) throw new Error('密码须为 10–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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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">10–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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,205 @@
|
||||
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('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)
|
||||
})
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = { publicPath: '/', productionSourceMap: false, devServer: { proxy: { '/api': { target: 'http://127.0.0.1:8000' } } } }
|
||||
@@ -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。已验证退出、禁用、密码重置和到期拒绝;该差异是为满足当前可撤销会话要求,不宣称上游默认实现满足此要求。
|
||||
|
||||
@@ -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 在事务提交后才返回成功。
|
||||
|
||||
@@ -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: 3f2294d5f038cc6f3c3e707a44f528c697c01b38
|
||||
synchronized_at: 2026-09-10T09:38:15Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -48,3 +48,13 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则
|
||||
学习端与管理端均使用账号(用户名)+密码登录,账号不要求邮箱格式;邮箱不作为必填登录标识。两端共用账号体系,管理权限由服务端单独校验。用户名唯一性和大小写规则在账号工单中细化。
|
||||
|
||||
私人学习记录必须由服务端强制按当前用户归属过滤;管理员角色、部门范围或关闭 go-admin 数据权限开关均不能自动解除学习接口的本人归属约束。共享词典和个人释义分开维护。
|
||||
|
||||
|
||||
## 账号与会话实现规则(#2)
|
||||
|
||||
- 账号去首尾空白并转小写,3~32 位、ASCII 字母开头,可含数字、点、下划线和连字符;唯一约束阻止大小写和并发重复。密码 10~72 字节,使用 bcrypt,不填写必需邮箱。
|
||||
- 初始管理员只通过显式 bootstrap 建立;库中已有账号时拒绝再次 bootstrap,不覆盖密码。管理页面只创建学习者,不能将输入角色升级为管理员;本阶段不提供账号删除。
|
||||
- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,8 小时过期。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。
|
||||
- 每次请求读取账号当前状态与会话有效期;学习数据由认证身份确定,管理员权限不能解除学习接口的本人过滤。
|
||||
- 两端分别使用 lexgo-admin-token、lexgo-learner-token 的 sessionStorage;401、退出和账号切换清除私人状态,以代次检查阻止迟到响应恢复旧数据。管理端拒绝学习者并撤销其新会话;撤销服务异常会提示未确认,不冒充服务端退出成功。
|
||||
- 新建账号拥有英语空空间。此处没有书籍、阅读、词典或复习功能,不把空页面视为学习业务已实现。
|
||||
|
||||
@@ -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: a0c03d3474ef610a4c842298db8c3df2f91054c7
|
||||
synchronized_at: 2026-09-10T09:38:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -87,3 +87,51 @@ 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 另行验证。
|
||||
|
||||
@@ -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: 139b4b3c61a26143577a8450a6abf53965737534
|
||||
synchronized_at: 2026-09-10T09:38:20Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -22,3 +22,14 @@ 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 大小警告来自当前选用工具链,不影响已验证构建;生产体积优化不作为本单完成条件。
|
||||
|
||||
@@ -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 的会话和权限测试不能证明尚未存在的书籍/词汇/复习权限已通过。
|
||||
|
||||
@@ -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
@@ -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 分支。
|
||||
|
||||
|
||||
## 当前实施入口
|
||||
|
||||
@@ -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/
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"ms-playwright.playwright"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
Generated
+2144
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤t, &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
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 := credentials(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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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":"short"}`,
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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) {
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
if !usernamePattern.MatchString(username) {
|
||||
return "", failure(400, "账号须为字母开头的 3~32 位字母、数字、点、下划线或连字符")
|
||||
}
|
||||
if len(password) < 10 || len(password) > 72 {
|
||||
return "", failure(400, "密码须为 10~72 字节")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
func digest(token string) string { v := sha256.Sum256([]byte(token)); return hex.EncodeToString(v[:]) }
|
||||
|
||||
func createAccount(tx *gorm.DB, username, password string, role, actor int) (Account, error) {
|
||||
name, err := credentials(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 := credentials(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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:删除时间"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user