feat: add auth provider boundary

This commit is contained in:
QiuSW
2026-07-08 23:37:19 +08:00
parent 811a9c889f
commit 5aaac0b166
19 changed files with 788 additions and 40 deletions
+3 -1
View File
@@ -8,7 +8,7 @@
- 后端:Go 1.23.0、Wails v3.0.0-alpha.9、SQLite
- 前端:Vue 3、TypeScript、Vite、Tailwind CSS、Ant Design Vue
- 当前功能:多语言、主题切换、本地配置、快捷键、OCR 示例
- 当前功能:多语言、主题切换、本地配置、快捷键、本地登录 provider、OCR 示例
- 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例
## 环境准备
@@ -69,6 +69,8 @@ Windows 下也可以直接运行:
`dev.bat` 会从 `9245` 开始选择一个空闲 Vite 端口,避免已有 dev server 占用端口时无法打开 GUI。
开发启动使用快速前端 dev 构建以尽快打开桌面窗口;提交前仍以 `npm run build` 做完整 TypeScript 检查和生产构建。
本地演示登录账号为 `admin` / `admin123`;密码由后端初始化为 hash,不写在前端页面里。
## 维护约定
- Go 模块名统一为 `cmbone`。
+15 -10
View File
@@ -13,7 +13,7 @@ main.go
├─ HotkeyService
├─ SystemInfo
├─ OCRService # 当前存量示例
├─ AuthService # 目标模块
├─ AuthService # 已实现 provider 边界
├─ AuditLogService # 目标模块
├─ AppLogService # 目标模块
├─ ImageOptimizationService # 目标业务样例
@@ -42,9 +42,12 @@ frontend/src
- `internal/services/ocr_service.go`:当前 OCR 存量示例,后续可演进为图片 AI 能力的一部分。
- `platform/`:隔离 Windows / macOS 热键和系统能力差异。
已实现边界:
- `AuthService`:登录、退出、会话读取;内部通过 `AuthProvider` 接入本地固定账号或真实后端。当前 `LocalAuthProvider` 可用,`RemoteAuthProvider` 保留边界并返回未配置错误。
目标新增边界:
- `AuthService`:登录、退出、会话读取;内部通过 `AuthProvider` 接入本地固定账号或真实后端。
- `AuditLogService`:记录和查询用户操作审计日志。
- `AppLogService`:记录和查询运行错误,必要时可落本地滚动日志文件。
- `ImageOptimizationService`:商品图片 AI 优化任务的创建、状态、结果和失败原因。
@@ -69,7 +72,7 @@ frontend/src
## 登录架构
登录必须通过 provider 边界实现:
登录已经通过 provider 边界实现:
```text
AuthService
@@ -79,8 +82,10 @@ AuthService
```
- `LocalAuthProvider`:用于模板演示和离线模式,固定账号密码由后端配置或安全哈希保存,不写死在前端。
- `RemoteAuthProvider`:用于真实后端,负责请求登录接口并保存 token / session。
- `RemoteAuthProvider`:用于真实后端,负责请求登录接口并保存 token / session;当前只保留接口边界并返回未配置错误。
- 前端只关心登录、退出和当前会话,不关心 provider 细节。
- 默认本地账号由 `appconfig` 初始化,用户名为 `admin`,默认密码为 `admin123`,密码以 salt + SHA-256 hash 形式保存。
- 登录成功、失败和退出会写入 `audit_logs`。
## 日志架构
@@ -118,8 +123,6 @@ appconfig(
)
```
目标新增表:
```sql
auth_sessions(
id INTEGER PRIMARY KEY,
@@ -127,8 +130,8 @@ auth_sessions(
username TEXT,
provider TEXT,
token TEXT,
created_at DATETIME,
expires_at DATETIME,
created_at TEXT,
expires_at TEXT,
active INTEGER
)
```
@@ -141,10 +144,12 @@ audit_logs(
target_type TEXT,
target_id TEXT,
detail TEXT,
created_at DATETIME
created_at TEXT
)
```
目标新增表:
```sql
app_logs(
id INTEGER PRIMARY KEY,
@@ -208,7 +213,7 @@ image_task_results(
- 第二窗口保持当前小窗定位,不参与最大化、尺寸恢复和主布局填充逻辑。
- 后续增强再保存主窗口尺寸和最大化状态,下次启动恢复用户选择。
实现状态:主窗口初始尺寸、居中、可调整大小、根容器和主布局填充已落地;主应用路由骨架已落地。窗口尺寸持久化依赖设置 schema 后续实现。
实现状态:主窗口初始尺寸、居中、可调整大小、根容器和主布局填充已落地;主应用路由骨架已落地;登录 provider 边界已落地。窗口尺寸持久化依赖设置 schema 后续实现。
## 关键风险
+1 -1
View File
@@ -29,7 +29,7 @@
| T-010 | 确定 Windows-first 窗口与布局策略 | T-002 | 主窗口默认不最大化,初始 `1280x800`,最小 `1024x720`,居中,可调整大小;页面根容器 `width: 100%`、`height: 100vh`;内部用 flex/grid 填满窗口;第二窗口保持小窗 | DONE |
| T-011 | 实现主窗口启动参数与根布局策略 | T-010 | Go 侧设置主窗口初始宽高、居中、可调整大小;前端根容器和主布局填满窗口;阅读型内容有最大宽度,列表/仪表盘/设置页可自适应拉伸 | DONE |
| T-012 | 搭建主应用路由骨架 | T-011 | `login`、`dashboard`、`image-optimizer`、`logs`、`statistics`、`settings`、`components` 路由可访问 | DONE |
| T-013 | 实现登录 provider 边界 | T-012 | 支持本地固定账号 provider;预留 remote provider;账号密码不写死在前端;登录事件写审计日志 | TODO |
| T-013 | 实现登录 provider 边界 | T-012 | 支持本地固定账号 provider;预留 remote provider;账号密码不写死在前端;登录事件写审计日志 | DONE |
| T-014 | 收敛设置模块 schema | T-012 | 主题、语言、窗口行为、快捷键、数据目录、日志策略都有统一配置定义和默认值 | TODO |
| T-015 | 持久化窗口尺寸和最大化状态 | T-014 | 保存主窗口尺寸和最大化状态;下次启动恢复用户上次选择;第二窗口不参与该逻辑 | TODO |
+11 -8
View File
@@ -61,24 +61,27 @@
- 失败时返回错误给前端。
- 平台依赖或能力缺失时,不应导致主窗口不可启动。
## 目标新增服务
### AuthService
目标方法:
文件:`internal/services/auth_service.go`
- `Login(username string, password string) (Session, error)`:登录并返回会话。
- `Logout() error`:退出当前会话。
- `GetCurrentSession() (Session, error)`:读取当前会话。
- `GetAuthProviders() []models.AuthProviderOption`:返回可用 provider 列表。
- `GetAuthMode() string`:读取当前 auth provider。
- `SetAuthMode(mode string) error`:切换 `local` 或 `remote` provider。
- `Login(provider string, username string, password string) (models.AuthSession, error)`:登录并返回会话。
- `Logout() error`:退出当前会话。
- `GetCurrentSession() (models.AuthSession, error)`:读取当前有效会话;无会话时返回空 session。
约束:
- 前端不得写死固定账号密码。
- `LocalAuthProvider` 只用于演示和离线模式。
- `RemoteAuthProvider` 用于真实后端登录。
- `LocalAuthProvider` 只用于演示和离线模式,默认账号来自后端初始化配置。
- 默认本地演示账号:`admin` / `admin123`;密码在数据库中以 salt + SHA-256 hash 保存,不在前端页面或绑定中保存。
- `RemoteAuthProvider` 用于真实后端登录,目前保留边界并返回未配置错误。
- 登录成功、失败和退出必须写审计日志。
## 目标新增服务
### AuditLogService
目标方法:
+16 -4
View File
@@ -6,7 +6,7 @@
项目处于可复用桌面应用模板的产品定位校准阶段。目标已经从迁移后的基础工程调整为“Windows-first、可扩展到其他平台的桌面端应用模板”。
当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例和模板级模块入口。登录、商品图片 AI 优化、审计日志、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和数据模型。
当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架和登录 provider 边界阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口和本地登录能力。商品图片 AI 优化、完整审计日志查询、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和业务数据模型。
## 已确认事实
@@ -14,7 +14,7 @@
- 主要运行场景:Windows 10 以及以后的 Windows 操作系统。
- 跨平台策略:Windows-first,保留 macOS / Linux 适配边界,不在 MVP 承诺完整体验。
- 第一批业务样例:电商商品图片 AI 优化。
- 登录策略:本地固定账号 provider + 真实后端 provider 的可替换边界。
- 登录策略:本地固定账号 provider 已实现,真实后端 provider 已预留边界。
- 日志策略:审计日志用于用户动作追踪,运行日志用于系统排错。
- 第一批统计指标:今日处理图片数、AI 优化成功率、平均处理耗时、失败任务数、今日操作日志数、最近 7 天处理趋势。
- Go 模块名:`cmbone`
@@ -24,6 +24,7 @@
- 前端框架:Vue 3、Vite、TypeScript、Tailwind CSS、Ant Design Vue
- 当前 Wails 前端 bindings:由 `wails3 build` / `wails3 generate bindings` 生成的 `.js` 文件。
- 本地数据库:SQLite,用户配置目录下 `cmbone/cmbone.db`
- 当前本地演示账号:`admin` / `admin123`,密码由后端初始化为 salt + SHA-256 hash,不写死在前端。
- 当前主窗口路由:`/#/` 重定向到 `/#/dashboard`。
- 当前主应用模块路由:`/#/login`、`/#/dashboard`、`/#/image-optimizer`、`/#/logs`、`/#/statistics`、`/#/settings`、`/#/components`。
- 当前兼容重定向路由:`/#/business/image-optimizer`、`/#/logs/audit`、`/#/logs/app`。
@@ -32,6 +33,8 @@
- 已实现页面布局策略:根容器 `width: 100%`、`height: 100vh`,内部 flex/grid 填满窗口。
- 已实现内容区策略:阅读型内容保留最大宽度;列表、仪表盘、设置页自适应拉伸。
- 已保留第二窗口策略:保持小窗,不参与主窗口最大化和尺寸恢复逻辑。
- 已实现 AuthService:`GetAuthProviders`、`GetAuthMode`、`SetAuthMode`、`Login`、`Logout`、`GetCurrentSession`。
- 已实现登录审计留痕:登录成功、登录失败和退出写入 `audit_logs`。
## 当前命令
@@ -66,7 +69,7 @@ wails3 build
- 模板范围容易膨胀,必须按任务小步实现。
- Wails v3 alpha 版本需要保持 CLI、Go 模块和前端 runtime 匹配。
- 登录 provider、AI provider、日志和统计模块尚未实现,当前只是文档目标。
- 真实后端登录 provider、AI provider、完整日志查询和统计模块尚未实现,当前仍是后续任务。
- OCR 和热键依赖平台实现,跨平台修改需要分别验证。
- 当前前端以构建检查为主,暂未引入专门的前端单元测试。
- bindings 生成依赖本机安装的 `wails3`。
@@ -124,9 +127,18 @@ wails3 build
- `frontend/src/router/index.ts` 已切换为主应用模块路由,第二窗口继续保留 `/#/second`。
- `npm run typecheck`、`npm run build`、`npm run build:dev`、`go test ./...`、`go build -o bin\cmbone.exe .`、`cmd /c build.bat` 和 `dev.bat` 烟测通过。
同日完成登录 provider 边界:
- 新增 `AuthService`、`LocalAuthProvider` 和 `RemoteAuthProvider`。
- 新增 `auth_sessions` 和 `audit_logs` 迁移。
- 登录页通过 Wails bindings 调用后端 AuthService,不在前端写固定账号密码。
- 默认本地演示账号为 `admin` / `admin123`,密码以后端配置 hash 形式保存。
- 登录成功、失败和退出会写审计日志。
- `go test ./...`、`npm run typecheck`、`npm run build`、`npm run build:dev`、`go build -o bin\cmbone.exe .`、`cmd /c build.bat` 和 `dev.bat` 烟测通过。
## 下一步
从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-013`:实现登录 provider 边界。
从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-014`:收敛设置模块 schema。
## 当前 blocker
+12 -2
View File
@@ -24,11 +24,21 @@
| `internal/services/appconfig_service.go` | `GetAppConfig` / `SetAppConfig` | 前端配置读写入口 |
| `internal/services/appconfig_service.go` | `GetLanguage` / `SetLanguage` | 语言配置读写入口 |
## 登录
| 文件 | 方法 / 内容 | 说明 |
| --- | --- | --- |
| `internal/services/auth_service.go` | `AuthService` | 登录、退出、当前会话和 auth mode |
| `internal/services/auth_service.go` | `LocalAuthProvider` | 校验后端初始化的本地固定账号 |
| `internal/services/auth_service.go` | `RemoteAuthProvider` | 真实后端登录 provider 预留边界 |
| `internal/models/auth.go` | `AuthSession` / `AuthProviderOption` | 前端绑定模型 |
| `frontend/src/modules/auth/LoginPage.vue` | 登录页 | 调用后端 AuthService,不写死账号密码 |
## 目标模板模块
| 模块 | 目标服务 | 说明 |
| --- | --- | --- |
| 登录 | `AuthService` | 本地固定账号 provider 和真实后端 provider |
| 登录 | `AuthService` | 已实现本地固定账号 provider,真实后端 provider 保留边界 |
| 审计日志 | `AuditLogService` | 记录登录、设置变更、任务操作、导出等用户动作 |
| 运行日志 | `AppLogService` | 记录 AI 调用、数据库、文件、热键等错误 |
| 商品图片 AI 优化 | `ImageOptimizationService` | 创建任务、查询状态、查看结果和失败原因 |
@@ -51,7 +61,7 @@
| `frontend/src/router/index.ts` | routes | 登录、主应用模块和第二窗口路由 |
| `frontend/src/layouts/MainLayout.vue` | 主窗口布局 | 侧边导航、顶部窗口区和 `RouterView` 内容区 |
| `frontend/src/components/template/ModulePageShell.vue` | 模块页共享壳 | 模块标题、状态和摘要卡片 |
| `frontend/src/modules/auth/LoginPage.vue` | 登录页骨架 | 后续接入 AuthProvider |
| `frontend/src/modules/auth/LoginPage.vue` | 登录页 | 调用 AuthService 登录并处理错误 |
| `frontend/src/modules/dashboard/DashboardPage.vue` | 工作台骨架 | P0 指标和近期任务入口 |
| `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主业务骨架 | 商品图片 AI 优化任务入口 |
| `frontend/src/modules/logs/LogsPage.vue` | 日志页骨架 | 审计日志和运行日志入口 |
+2 -2
View File
@@ -9,7 +9,7 @@
| 路由 | 组件 | 窗口 | 职责 |
| --- | --- | --- | --- |
| `#/` | `frontend/src/layouts/MainLayout.vue` | 主窗口 | 重定向到 `#/dashboard` |
| `#/login` | `frontend/src/modules/auth/LoginPage.vue` | 主窗口 | 登录入口骨架,后续接入本地固定账号 provider 和真实后端 provider |
| `#/login` | `frontend/src/modules/auth/LoginPage.vue` | 主窗口 | 登录入口,已接入本地固定账号 provider,真实后端 provider 保留边界 |
| `#/dashboard` | `frontend/src/modules/dashboard/DashboardPage.vue` | 主窗口 | 工作台,展示 P0 指标和近期任务入口 |
| `#/image-optimizer` | `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主窗口 | 商品图片 AI 优化业务样例骨架 |
| `#/logs` | `frontend/src/modules/logs/LogsPage.vue` | 主窗口 | 审计日志和运行日志统一入口骨架 |
@@ -25,7 +25,7 @@
| 路由 | 页面 | 职责 |
| --- | --- | --- |
| `#/login` | 登录页 | `T-013` 接入本地固定账号登录或真实后端登录 |
| `#/login` | 登录页 | 已接入本地固定账号登录;真实后端登录待后续接入 |
| `#/image-optimizer` | 商品图片 AI 优化 | `T-020` / `T-023` 接入任务模型、任务列表、状态、结果和失败原因 |
| `#/logs` | 操作日志 | `T-021` 接入审计日志和运行日志查询 |
| `#/statistics` | 数据统计 | `T-022` 接入趋势、成功率、耗时和失败任务 |
@@ -6,6 +6,132 @@
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
export class AuthProviderOption {
/**
* Creates a new AuthProviderOption instance.
* @param {Partial<AuthProviderOption>} [$$source = {}] - The source object to create the AuthProviderOption.
*/
constructor($$source = {}) {
if (!("value" in $$source)) {
/**
* @member
* @type {string}
*/
this["value"] = "";
}
if (!("label" in $$source)) {
/**
* @member
* @type {string}
*/
this["label"] = "";
}
if (!("description" in $$source)) {
/**
* @member
* @type {string}
*/
this["description"] = "";
}
if (!("enabled" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["enabled"] = false;
}
Object.assign(this, $$source);
}
/**
* Creates a new AuthProviderOption instance from a string or object.
* @param {any} [$$source = {}]
* @returns {AuthProviderOption}
*/
static createFrom($$source = {}) {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new AuthProviderOption(/** @type {Partial<AuthProviderOption>} */($$parsedSource));
}
}
export class AuthSession {
/**
* Creates a new AuthSession instance.
* @param {Partial<AuthSession>} [$$source = {}] - The source object to create the AuthSession.
*/
constructor($$source = {}) {
if (!("id" in $$source)) {
/**
* @member
* @type {number}
*/
this["id"] = 0;
}
if (!("userId" in $$source)) {
/**
* @member
* @type {string}
*/
this["userId"] = "";
}
if (!("username" in $$source)) {
/**
* @member
* @type {string}
*/
this["username"] = "";
}
if (!("provider" in $$source)) {
/**
* @member
* @type {string}
*/
this["provider"] = "";
}
if (!("token" in $$source)) {
/**
* @member
* @type {string}
*/
this["token"] = "";
}
if (!("createdAt" in $$source)) {
/**
* @member
* @type {string}
*/
this["createdAt"] = "";
}
if (!("expiresAt" in $$source)) {
/**
* @member
* @type {string}
*/
this["expiresAt"] = "";
}
if (!("active" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["active"] = false;
}
Object.assign(this, $$source);
}
/**
* Creates a new AuthSession instance from a string or object.
* @param {any} [$$source = {}]
* @returns {AuthSession}
*/
static createFrom($$source = {}) {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new AuthSession(/** @type {Partial<AuthSession>} */($$parsedSource));
}
}
export class Hotkey {
/**
* Creates a new Hotkey instance.
@@ -0,0 +1,80 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
/**
* @returns {Promise<string> & { cancel(): void }}
*/
export function GetAuthMode() {
let $resultPromise = /** @type {any} */($Call.ByID(1527507258));
return $resultPromise;
}
/**
* @returns {Promise<models$0.AuthProviderOption[]> & { cancel(): void }}
*/
export function GetAuthProviders() {
let $resultPromise = /** @type {any} */($Call.ByID(2710846997));
let $typingPromise = /** @type {any} */($resultPromise.then(($result) => {
return $$createType1($result);
}));
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* @returns {Promise<models$0.AuthSession> & { cancel(): void }}
*/
export function GetCurrentSession() {
let $resultPromise = /** @type {any} */($Call.ByID(2579662840));
let $typingPromise = /** @type {any} */($resultPromise.then(($result) => {
return $$createType2($result);
}));
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* @param {string} provider
* @param {string} username
* @param {string} password
* @returns {Promise<models$0.AuthSession> & { cancel(): void }}
*/
export function Login(provider, username, password) {
let $resultPromise = /** @type {any} */($Call.ByID(4113748438, provider, username, password));
let $typingPromise = /** @type {any} */($resultPromise.then(($result) => {
return $$createType2($result);
}));
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* @returns {Promise<void> & { cancel(): void }}
*/
export function Logout() {
let $resultPromise = /** @type {any} */($Call.ByID(3585850903));
return $resultPromise;
}
/**
* @param {string} mode
* @returns {Promise<void> & { cancel(): void }}
*/
export function SetAuthMode(mode) {
let $resultPromise = /** @type {any} */($Call.ByID(3911115982, mode));
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.AuthProviderOption.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = models$0.AuthSession.createFrom;
@@ -4,12 +4,14 @@
import * as AppConfigService from "./appconfigservice.js";
import * as AppService from "./appservice.js";
import * as AuthService from "./authservice.js";
import * as HotkeyService from "./hotkeyservice.js";
import * as OCRService from "./ocrservice.js";
import * as SystemInfo from "./systeminfo.js";
export {
AppConfigService,
AppService,
AuthService,
HotkeyService,
OCRService,
SystemInfo
+10 -2
View File
@@ -17,6 +17,7 @@ import {
} from '@ant-design/icons-vue'
import { Window } from '@wailsio/runtime'
import { OpenSecondWindow } from '../../bindings/cmbone/internal/services/appservice'
import { GetCurrentSession } from '../../bindings/cmbone/internal/services/authservice'
import { getOS, OS_READY } from '../utils/osinfo'
type NavItem = {
@@ -30,6 +31,8 @@ const route = useRoute()
const isWindows = ref(false)
const isMacOS = ref(false)
const isMaximized = ref(false)
const sessionUsername = ref('未登录')
const sessionProvider = ref('Auth Provider')
const navItems: NavItem[] = [
{ path: '/dashboard', label: '工作台', detail: '指标与近期任务', icon: DashboardOutlined },
@@ -59,6 +62,11 @@ onMounted(async () => {
if (isWindows.value) {
isMaximized.value = await Window.IsMaximised()
}
const session = await GetCurrentSession()
if (session.active) {
sessionUsername.value = session.username
sessionProvider.value = session.provider
}
})
</script>
@@ -102,10 +110,10 @@ onMounted(async () => {
</div>
<div class="min-w-0">
<p class="mb-0 truncate text-sm font-medium text-slate-900 dark:text-white">
本地演示用户
{{ sessionUsername }}
</p>
<p class="mb-0 truncate text-xs text-slate-500 dark:text-slate-400">
Local Provider
{{ sessionProvider }}
</p>
</div>
</div>
+42 -9
View File
@@ -1,15 +1,40 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { DashboardOutlined } from '@ant-design/icons-vue'
import { GetAuthMode, GetAuthProviders, Login } from '../../../bindings/cmbone/internal/services/authservice'
type ProviderOption = {
value: string
label: string
description: string
enabled: boolean
}
const router = useRouter()
const username = ref('')
const password = ref('')
const provider = ref('local')
const providers = ref<ProviderOption[]>([])
const errorMessage = ref('')
const isSubmitting = ref(false)
function submitLogin() {
router.push('/dashboard')
onMounted(async () => {
providers.value = await GetAuthProviders()
provider.value = await GetAuthMode()
})
async function submitLogin() {
errorMessage.value = ''
isSubmitting.value = true
try {
await Login(provider.value, username.value, password.value)
await router.push('/dashboard')
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isSubmitting.value = false
}
}
</script>
@@ -34,23 +59,31 @@ function submitLogin() {
<label class="block">
<span class="mb-1 block text-sm font-medium">Provider</span>
<select v-model="provider" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950">
<option value="local">Local</option>
<option value="remote">Remote</option>
<option v-for="item in providers" :key="item.value" :value="item.value" :disabled="!item.enabled">
{{ item.label }}{{ item.enabled ? '' : '(未配置)' }}
</option>
</select>
<span class="mt-1 block text-xs text-slate-500 dark:text-slate-400">
{{ providers.find((item) => item.value === provider)?.description || '登录 provider' }}
</span>
</label>
<label class="block">
<span class="mb-1 block text-sm font-medium">账号</span>
<input v-model="username" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950" autocomplete="username" />
<input v-model="username" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950" autocomplete="username" placeholder="请输入账号" />
</label>
<label class="block">
<span class="mb-1 block text-sm font-medium">密码</span>
<input v-model="password" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950" type="password" autocomplete="current-password" />
<input v-model="password" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950" type="password" autocomplete="current-password" placeholder="请输入密码" />
</label>
<button class="h-10 w-full rounded-md bg-slate-950 text-sm font-medium text-white hover:bg-slate-800 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200" type="submit">
进入应用
<p v-if="errorMessage" class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-900/50 dark:bg-rose-950/40 dark:text-rose-200">
{{ errorMessage }}
</p>
<button class="h-10 w-full rounded-md bg-slate-950 text-sm font-medium text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200" type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '登录中' : '进入应用' }}
</button>
</form>
</section>
+29
View File
@@ -0,0 +1,29 @@
package models
type AuthProviderOption struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
}
type AuthSession struct {
ID int64 `json:"id"`
UserID string `json:"userId"`
Username string `json:"username"`
Provider string `json:"provider"`
Token string `json:"token"`
CreatedAt string `json:"createdAt"`
ExpiresAt string `json:"expiresAt"`
Active bool `json:"active"`
}
type AuditLog struct {
ID int64 `json:"id"`
Actor string `json:"actor"`
Action string `json:"action"`
TargetType string `json:"targetType"`
TargetID string `json:"targetId"`
Detail string `json:"detail"`
CreatedAt string `json:"createdAt"`
}
+284
View File
@@ -0,0 +1,284 @@
package services
import (
"cmbone/internal/models"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
const (
authProviderLocal = "local"
authProviderRemote = "remote"
auditActionLoginSuccess = "auth.login.success"
auditActionLoginFailure = "auth.login.failure"
auditActionLogout = "auth.logout"
)
var (
errInvalidCredentials = errors.New("用户名或密码错误")
errRemoteProviderNotReady = errors.New("remote auth provider is not configured")
errUnsupportedAuthProvider = errors.New("unsupported auth provider")
errMissingUsernameOrPassword = errors.New("用户名和密码不能为空")
)
type AuthService struct {
store *SuiStore
providers map[string]AuthProvider
}
type AuthProvider interface {
Mode() string
Authenticate(username string, password string) (authPrincipal, error)
}
type authPrincipal struct {
UserID string
Username string
Provider string
}
func NewAuthService(store *SuiStore) *AuthService {
service := &AuthService{store: store}
service.providers = map[string]AuthProvider{
authProviderLocal: &LocalAuthProvider{store: store},
authProviderRemote: &RemoteAuthProvider{},
}
return service
}
func (s *AuthService) GetAuthProviders() []models.AuthProviderOption {
return []models.AuthProviderOption{
{
Value: authProviderLocal,
Label: "Local",
Description: "本地固定账号 provider,用于模板演示和离线模式",
Enabled: true,
},
{
Value: authProviderRemote,
Label: "Remote",
Description: "真实后端 provider 预留入口",
Enabled: false,
},
}
}
func (s *AuthService) GetAuthMode() string {
mode, err := NewAppConfigService(s.store).GetAppConfig("auth.provider")
if err != nil || mode == "" {
return authProviderLocal
}
return mode
}
func (s *AuthService) SetAuthMode(mode string) error {
if _, ok := s.providers[mode]; !ok {
return fmt.Errorf("%w: %s", errUnsupportedAuthProvider, mode)
}
return NewAppConfigService(s.store).SetAppConfig("auth.provider", mode)
}
func (s *AuthService) Login(provider string, username string, password string) (models.AuthSession, error) {
provider = strings.TrimSpace(provider)
username = strings.TrimSpace(username)
if provider == "" {
provider = s.GetAuthMode()
}
if username == "" || password == "" {
_ = s.recordAudit(username, auditActionLoginFailure, "auth_session", "", "missing username or password")
return models.AuthSession{}, errMissingUsernameOrPassword
}
authProvider, ok := s.providers[provider]
if !ok {
_ = s.recordAudit(username, auditActionLoginFailure, "auth_session", provider, "unsupported provider")
return models.AuthSession{}, fmt.Errorf("%w: %s", errUnsupportedAuthProvider, provider)
}
principal, err := authProvider.Authenticate(username, password)
if err != nil {
_ = s.recordAudit(username, auditActionLoginFailure, "auth_session", provider, err.Error())
return models.AuthSession{}, err
}
session, err := s.createSession(principal)
if err != nil {
return models.AuthSession{}, err
}
_ = s.SetAuthMode(provider)
_ = s.recordAudit(principal.Username, auditActionLoginSuccess, "auth_session", fmt.Sprint(session.ID), "login succeeded")
return session, nil
}
func (s *AuthService) Logout() error {
session, err := s.GetCurrentSession()
if err != nil {
return err
}
if !session.Active {
return nil
}
_, err = s.store.DB.Exec("UPDATE auth_sessions SET active = 0 WHERE id = ?", session.ID)
if err != nil {
return err
}
return s.recordAudit(session.Username, auditActionLogout, "auth_session", fmt.Sprint(session.ID), "logout succeeded")
}
func (s *AuthService) GetCurrentSession() (models.AuthSession, error) {
now := time.Now().UTC().Format(time.RFC3339)
row := s.store.DB.QueryRow(`
SELECT id, user_id, username, provider, token, created_at, expires_at, active
FROM auth_sessions
WHERE active = 1 AND expires_at > ?
ORDER BY id DESC
LIMIT 1
`, now)
return scanAuthSession(row)
}
func (s *AuthService) createSession(principal authPrincipal) (models.AuthSession, error) {
token, err := newSessionToken()
if err != nil {
return models.AuthSession{}, err
}
now := time.Now().UTC()
expiresAt := now.Add(24 * time.Hour)
tx, err := s.store.DB.Begin()
if err != nil {
return models.AuthSession{}, err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
if _, err = tx.Exec("UPDATE auth_sessions SET active = 0 WHERE active = 1"); err != nil {
return models.AuthSession{}, err
}
result, err := tx.Exec(`
INSERT INTO auth_sessions (user_id, username, provider, token, created_at, expires_at, active)
VALUES (?, ?, ?, ?, ?, ?, 1)
`, principal.UserID, principal.Username, principal.Provider, token, now.Format(time.RFC3339), expiresAt.Format(time.RFC3339))
if err != nil {
return models.AuthSession{}, err
}
id, err := result.LastInsertId()
if err != nil {
return models.AuthSession{}, err
}
if err = tx.Commit(); err != nil {
return models.AuthSession{}, err
}
return models.AuthSession{
ID: id,
UserID: principal.UserID,
Username: principal.Username,
Provider: principal.Provider,
Token: token,
CreatedAt: now.Format(time.RFC3339),
ExpiresAt: expiresAt.Format(time.RFC3339),
Active: true,
}, nil
}
func (s *AuthService) recordAudit(actor string, action string, targetType string, targetID string, detail string) error {
if strings.TrimSpace(actor) == "" {
actor = "anonymous"
}
_, err := s.store.DB.Exec(`
INSERT INTO audit_logs (actor, action, target_type, target_id, detail, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`, actor, action, targetType, targetID, detail, time.Now().UTC().Format(time.RFC3339))
return err
}
func scanAuthSession(row *sql.Row) (models.AuthSession, error) {
var session models.AuthSession
var active int
err := row.Scan(
&session.ID,
&session.UserID,
&session.Username,
&session.Provider,
&session.Token,
&session.CreatedAt,
&session.ExpiresAt,
&active,
)
if errors.Is(err, sql.ErrNoRows) {
return models.AuthSession{}, nil
}
if err != nil {
return models.AuthSession{}, err
}
session.Active = active == 1
return session, nil
}
func newSessionToken() (string, error) {
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return "", err
}
return hex.EncodeToString(token), nil
}
type LocalAuthProvider struct {
store *SuiStore
}
func (p *LocalAuthProvider) Mode() string {
return authProviderLocal
}
func (p *LocalAuthProvider) Authenticate(username string, password string) (authPrincipal, error) {
config := NewAppConfigService(p.store)
expectedUsername, err := config.GetAppConfig("auth.local.username")
if err != nil {
return authPrincipal{}, err
}
salt, err := config.GetAppConfig("auth.local.password_salt")
if err != nil {
return authPrincipal{}, err
}
expectedHash, err := config.GetAppConfig("auth.local.password_hash")
if err != nil {
return authPrincipal{}, err
}
if username != expectedUsername || passwordHash(salt, password) != expectedHash {
return authPrincipal{}, errInvalidCredentials
}
return authPrincipal{
UserID: "local:" + username,
Username: username,
Provider: p.Mode(),
}, nil
}
type RemoteAuthProvider struct{}
func (p *RemoteAuthProvider) Mode() string {
return authProviderRemote
}
func (p *RemoteAuthProvider) Authenticate(username string, password string) (authPrincipal, error) {
return authPrincipal{}, errRemoteProviderNotReady
}
func passwordHash(salt string, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:])
}
+21
View File
@@ -81,6 +81,27 @@ func Migrate(db *sql.DB) error {
description TEXT,
state INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS auth_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
provider TEXT NOT NULL,
token TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
detail TEXT NOT NULL,
created_at TEXT NOT NULL
);
`)
if err != nil {
return err
+94
View File
@@ -2,6 +2,7 @@ package services
import (
"database/sql"
"errors"
"testing"
)
@@ -62,3 +63,96 @@ func TestAppConfigServiceLanguage(t *testing.T) {
t.Fatalf("updated language = %q, want en", got)
}
}
func TestAuthServiceLocalLoginLifecycle(t *testing.T) {
store := newTestStore(t)
service := NewAuthService(store)
session, err := service.Login("local", "admin", "admin123")
if err != nil {
t.Fatalf("login: %v", err)
}
if !session.Active {
t.Fatal("session is not active")
}
if session.Username != "admin" {
t.Fatalf("username = %q, want admin", session.Username)
}
if session.Provider != "local" {
t.Fatalf("provider = %q, want local", session.Provider)
}
if session.Token == "" {
t.Fatal("session token is empty")
}
current, err := service.GetCurrentSession()
if err != nil {
t.Fatalf("get current session: %v", err)
}
if current.ID != session.ID {
t.Fatalf("current session id = %d, want %d", current.ID, session.ID)
}
var loginAuditCount int
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.login.success' AND actor = 'admin'").Scan(&loginAuditCount); err != nil {
t.Fatalf("count login audit logs: %v", err)
}
if loginAuditCount != 1 {
t.Fatalf("login audit count = %d, want 1", loginAuditCount)
}
if err := service.Logout(); err != nil {
t.Fatalf("logout: %v", err)
}
current, err = service.GetCurrentSession()
if err != nil {
t.Fatalf("get current session after logout: %v", err)
}
if current.Active {
t.Fatal("current session is active after logout")
}
var logoutAuditCount int
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.logout' AND actor = 'admin'").Scan(&logoutAuditCount); err != nil {
t.Fatalf("count logout audit logs: %v", err)
}
if logoutAuditCount != 1 {
t.Fatalf("logout audit count = %d, want 1", logoutAuditCount)
}
}
func TestAuthServiceLocalLoginFailureWritesAuditLog(t *testing.T) {
store := newTestStore(t)
service := NewAuthService(store)
_, err := service.Login("local", "admin", "bad-password")
if !errors.Is(err, errInvalidCredentials) {
t.Fatalf("login error = %v, want invalid credentials", err)
}
var auditCount int
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.login.failure' AND actor = 'admin'").Scan(&auditCount); err != nil {
t.Fatalf("count audit logs: %v", err)
}
if auditCount != 1 {
t.Fatalf("audit count = %d, want 1", auditCount)
}
}
func TestAuthServiceRemoteProviderReserved(t *testing.T) {
store := newTestStore(t)
service := NewAuthService(store)
_, err := service.Login("remote", "admin", "admin123")
if !errors.Is(err, errRemoteProviderNotReady) {
t.Fatalf("login error = %v, want remote provider not ready", err)
}
var auditCount int
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.login.failure' AND target_id = 'remote'").Scan(&auditCount); err != nil {
t.Fatalf("count audit logs: %v", err)
}
if auditCount != 1 {
t.Fatalf("audit count = %d, want 1", auditCount)
}
}
+13 -1
View File
@@ -40,5 +40,17 @@ func defaultHotkeysSQL() string {
func defaultAppConfigSQL() string {
return `
INSERT OR IGNORE INTO appconfig (key, type, value, description)
VALUES ('language', 'system', 'zh', '应用语言,支持 zh/en');`
VALUES ('language', 'system', 'zh', '应用语言,支持 zh/en');
INSERT OR IGNORE INTO appconfig (key, type, value, description)
VALUES ('auth.provider', 'system', 'local', '登录 provider,支持 local/remote');
INSERT OR IGNORE INTO appconfig (key, type, value, description)
VALUES ('auth.local.username', 'system', 'admin', '本地演示账号用户名');
INSERT OR IGNORE INTO appconfig (key, type, value, description)
VALUES ('auth.local.password_salt', 'system', 'cmbone-local-auth-v1', '本地演示账号密码盐');
INSERT OR IGNORE INTO appconfig (key, type, value, description)
VALUES ('auth.local.password_hash', 'system', '3e0bfcda604b19fef1f596bcade7469ff1ab8b427fdc998e9a8216101b002f4a', '本地演示账号密码哈希');`
}
+1
View File
@@ -30,6 +30,7 @@ func main() {
application.NewService(services.NewHotkeyService(store)),
application.NewService(services.NewSystemInfo()),
application.NewService(services.NewAppConfigService(store)),
application.NewService(services.NewAuthService(store)),
application.NewService(&services.OCRService{}),
},
Assets: application.AssetOptions{
+26
View File
@@ -173,3 +173,29 @@
- 并行运行 `npm run build:dev` 和 `go test/go build` 会因为 `frontend/dist` 被重写导致 Go embed 读取旧 hash 文件失败;正确做法是前端构建完成后再运行 Go 命令。
- `dev.bat` 日志仍有 `SetProcessDpiAwarenessContext failed ... Access is denied`,不影响窗口创建和页面加载。
- Browserslist 数据过期、Vite chunk 体积警告和 `wails3 build` alias 警告仍存在。
### 实现登录 provider 边界
- 任务:完成 `T-013`,支持本地固定账号 provider,预留 remote provider,账号密码不写死在前端,登录事件写审计日志。
- 结果:
- 新增 `internal/services/auth_service.go`,提供 `AuthService`、`LocalAuthProvider` 和 `RemoteAuthProvider`。
- 新增 `internal/models/auth.go`,提供 `AuthSession` 和 `AuthProviderOption` 前端绑定模型。
- SQLite 迁移新增 `auth_sessions` 和 `audit_logs`,并初始化本地演示账号配置。
- `main.go` 注册 `AuthService`,并重新生成 Wails `.js` bindings。
- 登录页通过 `AuthService.Login` 登录,Provider 列表来自后端,前端不保存固定账号密码。
- 主布局读取当前 session,并在侧边栏底部展示登录状态。
- 验证:
- `go test ./...` 通过,覆盖本地登录成功、登录失败审计、退出和 remote provider 预留错误。
- `cd frontend && npm run typecheck` 通过。
- `cd frontend && npm run build` 通过。
- `cd frontend && npm run build:dev` 通过。
- `go build -o bin\cmbone.exe .` 通过。
- `cmd /c build.bat` 通过,并重新生成 Wails `.js` bindings。
- `dev.bat` 烟测通过,主窗口可见,日志显示加载 `authservice.js` 并调用 `AuthService.GetCurrentSession`。
- 默认本地演示账号:
- 用户名:`admin`
- 密码:`admin123`
- 密码以后端配置的 salt + SHA-256 hash 保存,不写入前端页面。
- 非阻塞警告:
- `dev.bat` 日志仍有 `SetProcessDpiAwarenessContext failed ... Access is denied`,不影响窗口创建和页面加载。
- Browserslist 数据过期、Vite chunk 体积警告和 `wails3 build` alias 警告仍存在。