This commit is contained in:
2026-01-14 14:17:38 +08:00
commit f1e2ff6563
126 changed files with 13636 additions and 0 deletions

1
.buildflags Normal file
View File

@@ -0,0 +1 @@
-tags=sqlite_omit_load_extension

61
.gitignore vendored Normal file
View File

@@ -0,0 +1,61 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go vendor directory
vendor/
# Dependency directories
node_modules/
# Wails build output
build/bin/
# Frontend build output
web/dist/
web/src/wailsjs/
# Database files
*.db
*.sqlite
*.sqlite3
*.db.bak
# Environment files
.env
.env.local
.env.*.local
# Temporary files and directories
tmp/
temp/
*.tmp
.update-temp/
# Cache directories
.cache/
.vite/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
*.log.*

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# ssq-desk
双色球桌面查询应用,基于 Wails + Vue 3 + Arco Design 开发。
## 技术栈
- **前端**Vue 3 + Arco Design + TypeScript
- **后端**Go + Wails
- **数据**MySQL远程+ SQLite本地缓存
## 开发
### 前置要求
- Go 1.23+
- Node.js 18+
- Wails CLI v2.11.0+
### 安装依赖
```bash
# Go 依赖
go mod download
# 前端依赖
cd web
npm install
```
### 开发运行
```bash
wails dev
```
### 构建
```bash
wails build
```
## 项目结构
```
ssq-desk/
├─ app.go # Wails 应用结构
├─ main.go # 应用入口
├─ internal/ # 内部包
│ ├─ api/ # API 层
│ ├─ service/ # 业务逻辑层
│ ├─ storage/ # 存储层
│ └─ database/ # 数据库连接
├─ web/ # 前端目录
└─ docs/ # 文档目录
```
## 文档
详细文档请查看 [docs](./docs/) 目录。
---
> 项目维护者JueChen
> 创建时间2026-01-07

460
app.go Normal file
View File

@@ -0,0 +1,460 @@
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"ssq-desk/internal/api"
"ssq-desk/internal/database"
"ssq-desk/internal/module"
"ssq-desk/internal/service"
"ssq-desk/internal/storage/repository"
"time"
)
// App 应用结构体
type App struct {
ctx context.Context
moduleManager *module.Manager
}
// NewApp 创建新的应用实例
func NewApp() *App {
manager := module.NewManager()
// 注册模块(模块之间相互独立,松散耦合)
// SSQ 查询模块
ssqModule, _ := module.NewSsqModule()
if ssqModule != nil {
manager.Register(ssqModule)
}
// 授权码模块
authModule, _ := module.NewAuthModule()
if authModule != nil {
manager.Register(authModule)
}
// 版本更新模块
updateModule, _ := module.NewUpdateModule()
if updateModule != nil {
manager.Register(updateModule)
}
return &App{
moduleManager: manager,
}
}
// initLogFile 初始化日志文件
func initLogFile() {
// 获取用户配置目录
appDataDir, err := os.UserConfigDir()
if err != nil {
// 如果获取失败,使用当前目录
appDataDir = "."
}
// 创建日志目录
logDir := filepath.Join(appDataDir, "ssq-desk", "logs")
if err := os.MkdirAll(logDir, 0755); err != nil {
// 如果创建失败,使用当前目录
logDir = "."
}
// 生成日志文件名(按日期)
logFileName := fmt.Sprintf("ssq-desk-%s.log", time.Now().Format("20060102"))
logFilePath := filepath.Join(logDir, logFileName)
// 打开日志文件(追加模式)
logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
// 如果打开失败,继续使用标准输出
return
}
// 设置日志输出到文件和控制台(同时输出)
if logFile != nil {
// 创建多写入器,同时写入文件和控制台
multiWriter := &multiWriterType{
file: logFile,
console: os.Stdout,
}
// 设置日志格式:日期时间 + 日志内容
log.SetOutput(multiWriter)
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Printf("[日志] 日志文件已初始化: %s", logFilePath)
} else {
// 如果无法创建日志文件,使用标准输出
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
}
}
// multiWriterType 多写入器,同时写入文件和控制台
type multiWriterType struct {
file *os.File
console *os.File
}
func (m *multiWriterType) Write(p []byte) (n int, err error) {
// 写入文件
if m.file != nil {
m.file.Write(p)
}
// 写入控制台
if m.console != nil {
m.console.Write(p)
}
return len(p), nil
}
// startup 应用启动时调用
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// 初始化日志文件
initLogFile()
// 初始化 SQLite 本地数据库
_, err := database.InitSQLite()
if err != nil {
log.Printf("[启动] SQLite 初始化失败: %v", err)
} else {
log.Printf("[启动] SQLite 初始化成功")
}
// 初始化 MySQL 远程数据库(可选,用于数据同步)
_, err = database.InitMySQL()
if err != nil {
log.Printf("[启动] MySQL 连接失败: %v", err)
} else {
log.Printf("[启动] MySQL 连接成功")
}
// 初始化所有模块
if err := a.moduleManager.InitAll(ctx); err != nil {
log.Printf("[启动] 模块初始化失败: %v", err)
} else {
log.Printf("[启动] 模块初始化成功")
}
// 启动所有模块
if err := a.moduleManager.StartAll(ctx); err != nil {
log.Printf("[启动] 模块启动失败: %v", err)
} else {
log.Printf("[启动] 模块启动成功")
}
}
// getSsqModule 获取 SSQ 模块
func (a *App) getSsqModule() *module.SsqModule {
if m, ok := a.moduleManager.Get("ssq"); ok {
if ssqModule, ok := m.(*module.SsqModule); ok {
return ssqModule
}
}
return nil
}
// getAuthModule 获取 Auth 模块
func (a *App) getAuthModule() *module.AuthModule {
if m, ok := a.moduleManager.Get("auth"); ok {
if authModule, ok := m.(*module.AuthModule); ok {
return authModule
}
}
return nil
}
// Greet 测试方法
func (a *App) Greet(name string) string {
return "Hello " + name + ", It's show time!"
}
// QueryHistory 查询历史数据
func (a *App) QueryHistory(req api.QueryRequest) (map[string]interface{}, error) {
ssqModule := a.getSsqModule()
if ssqModule == nil {
_, err := api.NewSsqAPI()
return nil, err
}
ssqAPI := ssqModule.SsqAPI()
if ssqAPI == nil {
_, err := api.NewSsqAPI()
return nil, err
}
result, err := ssqAPI.QueryHistory(req)
if err != nil {
return nil, err
}
// 转换为 map 返回Wails 需要可序列化的类型)
return map[string]interface{}{
"total": result.Total,
"summary": result.Summary,
"details": result.Details,
}, nil
}
// ActivateLicense 激活授权码
func (a *App) ActivateLicense(licenseCode string) (map[string]interface{}, error) {
authModule := a.getAuthModule()
if authModule == nil {
_, err := api.NewAuthAPI()
return nil, err
}
authAPI := authModule.AuthAPI()
if authAPI == nil {
_, err := api.NewAuthAPI()
return nil, err
}
return authAPI.ActivateLicense(licenseCode)
}
// GetAuthStatus 获取授权状态
func (a *App) GetAuthStatus() (map[string]interface{}, error) {
authModule := a.getAuthModule()
if authModule == nil {
_, err := api.NewAuthAPI()
return nil, err
}
authAPI := authModule.AuthAPI()
if authAPI == nil {
_, err := api.NewAuthAPI()
return nil, err
}
return authAPI.GetAuthStatus()
}
// GetDeviceID 获取设备ID
func (a *App) GetDeviceID() (string, error) {
return service.GetDeviceID()
}
// SyncData 同步数据
func (a *App) SyncData() (map[string]interface{}, error) {
syncAPI, err := api.NewSyncAPI()
if err != nil {
return nil, err
}
return syncAPI.Sync()
}
// GetSyncStatus 获取同步状态
func (a *App) GetSyncStatus() (map[string]interface{}, error) {
syncAPI, err := api.NewSyncAPI()
if err != nil {
return nil, err
}
return syncAPI.GetSyncStatus()
}
// GetDataStats 获取数据统计
func (a *App) GetDataStats() (map[string]interface{}, error) {
ssqModule := a.getSsqModule()
if ssqModule == nil {
return nil, fmt.Errorf("SSQ 模块未初始化")
}
repo, err := repository.NewSQLiteSsqRepository()
if err != nil {
return nil, err
}
count, err := repo.Count()
if err != nil {
return nil, err
}
latestIssue, err := repo.GetLatestIssue()
if err != nil {
return nil, err
}
return map[string]interface{}{
"total_count": count,
"latest_issue": latestIssue,
}, nil
}
// BackupData 备份数据
func (a *App) BackupData() (map[string]interface{}, error) {
backupAPI := api.NewBackupAPI()
return backupAPI.Backup()
}
// RestoreData 恢复数据
func (a *App) RestoreData(backupPath string) (map[string]interface{}, error) {
backupAPI := api.NewBackupAPI()
return backupAPI.Restore(backupPath)
}
// ListBackups 列出所有备份
func (a *App) ListBackups() (map[string]interface{}, error) {
backupAPI := api.NewBackupAPI()
return backupAPI.ListBackups()
}
// DownloadPackage 下载数据包
func (a *App) DownloadPackage(downloadURL string) (map[string]interface{}, error) {
packageAPI, err := api.NewPackageAPI()
if err != nil {
return nil, err
}
return packageAPI.DownloadPackage(downloadURL)
}
// ImportPackage 导入数据包
func (a *App) ImportPackage(packagePath string) (map[string]interface{}, error) {
packageAPI, err := api.NewPackageAPI()
if err != nil {
return nil, err
}
return packageAPI.ImportPackage(packagePath)
}
// CheckPackageUpdate 检查数据包更新
func (a *App) CheckPackageUpdate(remoteURL string) (map[string]interface{}, error) {
packageAPI, err := api.NewPackageAPI()
if err != nil {
return nil, err
}
return packageAPI.CheckPackageUpdate(remoteURL)
}
// ListLocalPackages 列出本地数据包
func (a *App) ListLocalPackages() (map[string]interface{}, error) {
packageAPI, err := api.NewPackageAPI()
if err != nil {
return nil, err
}
return packageAPI.ListLocalPackages()
}
// getUpdateModule 获取更新模块
func (a *App) getUpdateModule() *module.UpdateModule {
if m, ok := a.moduleManager.Get("update"); ok {
if updateModule, ok := m.(*module.UpdateModule); ok {
return updateModule
}
}
return nil
}
// CheckUpdate 检查更新
func (a *App) CheckUpdate() (map[string]interface{}, error) {
updateModule := a.getUpdateModule()
if updateModule == nil {
return nil, fmt.Errorf("更新模块未初始化")
}
updateAPI := updateModule.UpdateAPI()
if updateAPI == nil {
return nil, fmt.Errorf("更新 API 未初始化")
}
return updateAPI.CheckUpdate()
}
// GetCurrentVersion 获取当前版本号
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
updateModule := a.getUpdateModule()
if updateModule == nil {
return nil, fmt.Errorf("更新模块未初始化")
}
updateAPI := updateModule.UpdateAPI()
if updateAPI == nil {
return nil, fmt.Errorf("更新 API 未初始化")
}
return updateAPI.GetCurrentVersion()
}
// GetUpdateConfig 获取更新配置
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
updateModule := a.getUpdateModule()
if updateModule == nil {
return nil, fmt.Errorf("更新模块未初始化")
}
updateAPI := updateModule.UpdateAPI()
if updateAPI == nil {
return nil, fmt.Errorf("更新 API 未初始化")
}
return updateAPI.GetUpdateConfig()
}
// SetUpdateConfig 设置更新配置
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
updateModule := a.getUpdateModule()
if updateModule == nil {
return nil, fmt.Errorf("更新模块未初始化")
}
updateAPI := updateModule.UpdateAPI()
if updateAPI == nil {
return nil, fmt.Errorf("更新 API 未初始化")
}
return updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
}
// DownloadUpdate 下载更新包
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
updateModule := a.getUpdateModule()
if updateModule == nil {
return nil, fmt.Errorf("更新模块未初始化")
}
updateAPI := updateModule.UpdateAPI()
if updateAPI == nil {
return nil, fmt.Errorf("更新 API 未初始化")
}
// 确保 context 已设置(用于事件推送)
if a.ctx != nil {
updateAPI.SetContext(a.ctx)
}
return updateAPI.DownloadUpdate(downloadURL)
}
// InstallUpdate 安装更新包
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
updateModule := a.getUpdateModule()
if updateModule == nil {
return nil, fmt.Errorf("更新模块未初始化")
}
updateAPI := updateModule.UpdateAPI()
if updateAPI == nil {
return nil, fmt.Errorf("更新 API 未初始化")
}
return updateAPI.InstallUpdate(installerPath, autoRestart)
}
// VerifyUpdateFile 验证更新文件哈希值
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
updateModule := a.getUpdateModule()
if updateModule == nil {
return nil, fmt.Errorf("更新模块未初始化")
}
updateAPI := updateModule.UpdateAPI()
if updateAPI == nil {
return nil, fmt.Errorf("更新 API 未初始化")
}
return updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
}

BIN
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
build/windows/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

15
build/windows/info.json Normal file
View File

@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

36
docs/00-文档目录.md Normal file
View File

@@ -0,0 +1,36 @@
# 文档目录
## 1. 规范文档
1.1 [开发规范](./01-规范/01-开发规范.md)
1.2 [Wails 绑定规范](./01-规范/02-接口规范.md)
1.3 [数据存储规范](./01-规范/03-数据库规范.md)
1.4 [工作事项推进日志规范](./01-规范/05-工作事项推进日志规范.md)
## 1.5 数据库脚本
1.5.1 [数据库初始化脚本](./01-数据库/2026-01-07-SSQ-init.sql)
## 2. 技术文档
2.1 [项目架构设计](./02-技术文档/项目架构设计.md)
## 3. 业务模块
3.1 [双色球查询业务](./03-业务模块/双色球查询业务.md)
## 4. 功能迭代
4.1 [双色球查询功能需求](./04-功能迭代/双色球查询功能需求.md)
4.2 [版本更新功能任务规划](./04-功能迭代/版本更新/任务规划.md)
4.3 [授权码功能设计](./04-功能迭代/授权码功能/授权码功能设计.md)
## 5. 任务规划
5.1 [任务规划](./任务规划.md)
## 6. 问题处理
待补充
## 7. 工作事项推进日志
7.1 [工作事项推进日志](./TODO-LIST.md)
7.2 [项目开发状态](./PROJECT-STATUS.md)
---
> 文档更新时间2026-01-07
> 文档维护者JueChen

View File

@@ -0,0 +1 @@
# 数据库文档目录

View File

@@ -0,0 +1,63 @@
-- ============================================
-- 双色球桌面应用数据库初始化脚本
-- ============================================
-- 创建时间2026-01-07
-- 维护者JueChen
--
-- 说明:
-- 1. 此脚本用于 MySQL 远程数据库初始化
-- 2. 表结构由程序通过 GORM AutoMigrate 自动创建,此脚本作为参考
-- 3. 时间字段由程序显式设置,不使用数据库默认值
-- 4. 如需手动执行请确保数据库已创建ssq
-- ============================================
-- ============================================
-- 1. 双色球历史开奖数据表 (ssq_history)
-- ============================================
-- 用途:存储双色球历史开奖数据
-- 使用场景MySQL 远程数据库和 SQLite 本地数据库
CREATE TABLE IF NOT EXISTS `ssq_history` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`issue_number` VARCHAR(20) NOT NULL COMMENT '期号如2025145',
`open_date` DATE NULL COMMENT '开奖日期(允许为空)',
`red_ball_1` TINYINT NOT NULL COMMENT '红球1范围1-33',
`red_ball_2` TINYINT NOT NULL COMMENT '红球2范围1-33',
`red_ball_3` TINYINT NOT NULL COMMENT '红球3范围1-33',
`red_ball_4` TINYINT NOT NULL COMMENT '红球4范围1-33',
`red_ball_5` TINYINT NOT NULL COMMENT '红球5范围1-33',
`red_ball_6` TINYINT NOT NULL COMMENT '红球6范围1-33',
`blue_ball` TINYINT NOT NULL COMMENT '蓝球范围1-16',
`created_at` DATETIME NOT NULL COMMENT '创建时间(由程序设置)',
`updated_at` DATETIME NOT NULL COMMENT '更新时间(由程序设置)',
PRIMARY KEY (`id`),
INDEX `idx_issue_number` (`issue_number`),
INDEX `idx_open_date` (`open_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='双色球历史开奖数据表用于MySQL远程数据库和SQLite本地数据库';
CREATE TABLE IF NOT EXISTS `sys_authorization_code` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`license_code` VARCHAR(100) NOT NULL COMMENT '授权码(唯一,用于标识授权)',
`device_id` VARCHAR(100) NOT NULL COMMENT '设备IDMD5哈希基于主机名、用户目录、操作系统生成用于设备绑定',
`activated_at` DATETIME NOT NULL COMMENT '激活时间(授权激活的时间)',
`expires_at` DATETIME NULL COMMENT '过期时间可选NULL表示永不过期',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态1:有效 0:无效)',
`created_at` DATETIME NOT NULL COMMENT '创建时间(由程序设置)',
`updated_at` DATETIME NOT NULL COMMENT '更新时间(由程序设置)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_license_code` (`license_code`),
INDEX `idx_device_id` (`device_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='授权信息表用于MySQL远程数据库和SQLite本地数据库';
CREATE TABLE IF NOT EXISTS `sys_version` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`version` VARCHAR(20) NOT NULL COMMENT '版本号语义化版本如1.0.0',
`download_url` VARCHAR(500) NULL COMMENT '下载地址更新包下载URL',
`changelog` TEXT NULL COMMENT '更新日志Markdown格式',
`force_update` TINYINT NOT NULL DEFAULT 0 COMMENT '是否强制更新1:是 0:否)',
`release_date` DATE NULL COMMENT '发布日期',
`created_at` DATETIME NOT NULL COMMENT '创建时间(由程序设置)',
`updated_at` DATETIME NOT NULL COMMENT '更新时间(由程序设置)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_version` (`version`),
INDEX `idx_release_date` (`release_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='版本信息表用于MySQL远程数据库存储应用版本发布信息';

View File

@@ -0,0 +1,68 @@
# 开发规范
## 1. 技术栈
- **前端**Vue 3 + Arco Design + TypeScript
- **后端**Go + Wails
- **远程数据库**MySQL历史数据源
- **本地存储**SQLite本地缓存+ 文件存储(配置/离线数据包)
## 2. 代码风格
- 统一使用 UTF-8 编码
- 前端遵循 ESLint 规则
- Go 代码遵循 `gofmt` 格式化
## 3. 命名规范
### 3.1 Go 后端
- 类型/结构体:大驼峰 `XxxService`
- 方法/变量:小驼峰 `GetList()`
- 常量:全大写下划线 `DEFAULT_PAGE_SIZE`
### 3.2 前端
- 组件:大驼峰 `XxxList.vue`
- 方法/变量:小驼峰 `getList()`
- 常量:全大写下划线 `DEFAULT_PAGE_SIZE`
## 4. Wails 绑定规范
- Go 结构体导出方法供前端调用
- 方法参数不超过3个超过时封装为结构体
- 方法名使用动词开头:`GetXxx``SaveXxx``DeleteXxx`
## 5. Arco Design 使用规范
- 优先使用 Arco 提供的组件和样式
- 避免过度自定义样式,保持主题兼容性
- 不使用 title 属性(如 `<a-card>`
- 颜色规范:
- 红球数字:`#F53F3F`Arco 红色)
- 蓝球数字:`#165DFF`Arco 蓝色)
- 未匹配数字:默认黑色
## 6. 代码质量要求
- 架构优良、性能良好、结构简单
- 方便维护,减少 AI 味
- 新增文件代码签名:`JueChen`
### 6.1 精准控制原则
- **精准定位问题**:找到问题的根本原因,而非到处添加防御性代码
- **精准设置样式**:只针对真正需要控制的元素设置样式,避免大量重复的 `overflow-x: hidden` 等防御性样式
- **可维护性优先**:代码改动时能明确知道哪个地方控制了什么,避免维护困难
### 6.2 主动性编程原则
- **主动解决问题**:找到问题的根源并修复,而不是用 `!important` 或大量覆盖样式来掩盖问题
- **主动思考设计**:考虑布局和样式设计的合理性,而非被动地添加防御性代码
- **主动优化代码**:定期审查代码,移除不必要的防御性代码,保持代码简洁
### 6.3 避免过度防御
- **不滥用 `!important`**:只在必要时使用,优先通过提高选择器优先级解决问题
- **不大量使用 `overflow-x: hidden`**:只在真正需要的地方使用,通常是 `body` 级别作为最后防线
- **不过度设置 `width: 100%`**:只在需要明确控制宽度的元素上设置
- **不重复设置相同样式**:避免在多个层级重复设置相同的样式属性
## 7. 版本控制
- Commit message`<type><subject>`,使用中文
- 提交前自检lint、功能测试
---
> 文档维护者JueChen
> 创建时间2026-01-07

View File

@@ -0,0 +1,53 @@
# Wails 绑定规范
## 1. 概述
Wails 通过 context 绑定 Go 方法供前端调用,无需 HTTP API。
## 2. Go 后端绑定
### 2.1 结构体定义
```go
type App struct {
ctx context.Context
}
func NewApp() *App {
return &App{}
}
```
### 2.2 方法绑定
- 导出方法(首字母大写)自动绑定
- 方法参数不超过3个超过时使用结构体
- 返回错误统一使用 `error` 类型
### 2.3 命名规范
- 查询:`GetXxx()``ListXxx()`
- 新增:`CreateXxx()``SaveXxx()`
- 更新:`UpdateXxx()`
- 删除:`DeleteXxx()`
## 3. 前端调用
### 3.1 调用方式
```typescript
// 导入绑定的方法
import { GetXxx, SaveXxx } from '@/wailsjs/go/main/App'
// 调用
const data = await GetXxx()
```
### 3.2 错误处理
- 统一使用 try-catch 处理
- 错误信息展示给用户
## 4. 参数规范
- 简单参数直接传递
- 复杂参数使用结构体/对象
- 字段命名使用小驼峰
---
> 文档维护者JueChen
> 创建时间2026-01-07

View File

@@ -0,0 +1,88 @@
# 数据存储规范
## 1. 存储架构
### 1.1 远程数据库MySQL
- **用途**:完整历史数据源
- **地址**39.99.243.191:3306
- **账号**u_ssq
- **密码**u_ssq@260106
- **连接方式**:需要时连接,支持离线模式
### 1.2 本地数据库SQLite
- **用途**:本地数据缓存、离线查询支持
- **位置**:应用数据目录
- **同步策略**:增量同步,定期更新
### 1.3 文件存储
- **配置文件**:应用配置、用户偏好
- **离线数据包**:完整历史数据导出/导入
- **缓存文件**:临时数据
### 1.4 前端存储
- **LocalStorage**临时数据、用户偏好、UI 状态
## 2. 数据库设计规范
### 2.1 表名规范
- 小写字母,单词间下划线:`ssq_history``query_cache`
### 2.2 字段规范
- 小写字母,单词间下划线:`issue_number``red_ball_1``created_at`
- 主键统一使用 `id`INT 或 BIGINT
- 时间字段:`created_at``updated_at`DATETIME
### 2.3 数据类型规范
- **期号**`VARCHAR(20)`,如 "2025145"
- **球号**`TINYINT`,范围 1-33红球或 1-16蓝球
- **日期**`DATE``DATETIME`
### 2.4 索引规范
- 主键索引:`PRIMARY KEY (id)`
- 查询索引:`INDEX idx_issue_number (issue_number)`
- 日期索引:`INDEX idx_open_date (open_date)`
## 3. 数据同步规范
### 3.1 同步策略
- **增量同步**:基于 `id``issue_number` 增量更新
- **全量同步**:首次安装或数据修复
- **同步时机**:应用启动检查、手动触发、定时任务
### 3.2 数据校验
- 期号唯一性校验
- 球号范围校验(红球 1-33蓝球 1-16
- 数据完整性校验
### 3.3 错误处理
- 网络错误:使用本地缓存
- 数据冲突:以远程为准或用户选择
- 同步失败:记录日志,下次重试
## 4. Go 结构体映射
### 4.1 ORM 工具
- 使用 `gorm` 进行 ORM 映射
- MySQL 和 SQLite 使用相同模型结构
### 4.2 结构体规范
```go
type SsqHistory struct {
ID int `gorm:"primaryKey" json:"id"`
IssueNumber string `gorm:"type:varchar(20);not null;index" json:"issue_number"`
OpenDate *time.Time `gorm:"type:date" json:"open_date"`
RedBall1 int `gorm:"type:tinyint;not null" json:"red_ball_1"`
// ... 其他字段
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
### 4.3 表名映射
- 实现 `TableName()` 方法指定表名
- 或使用 `gorm` 默认命名规则
---
> 文档维护者JueChen
> 创建时间2026-01-07

View File

@@ -0,0 +1,43 @@
# 工作事项推进日志规范
## 1. 日志格式
### 1.1 基本信息
- 事项标题
- 创建时间
- 状态(待办/进行中/已完成/已取消)
- 优先级(高/中/低)
### 1.2 详细内容
- 需求描述
- 实现方案
- 进展情况
- 遇到的问题
- 解决方案
### 1.3 时间记录
- 创建时间
- 开始时间
- 完成时间
- 重要节点时间
## 2. 更新规范
### 2.1 更新频率
- 重要事项每日更新
- 普通事项按进度更新
- 完成后及时标记
### 2.2 更新内容
- 记录关键进度节点
- 记录遇到的问题和解决方案
- 记录重要的决策和变更
## 3. 日志位置
- 主要日志:`docs/TODO-LIST.md`
- 功能迭代详细日志:`docs/04-功能迭代/{功能名称}/`
---
> 文档维护者JueChen
> 创建时间2026-01-07

View File

@@ -0,0 +1 @@
# 技术文档目录

View File

@@ -0,0 +1,161 @@
# 模块化架构设计
## 1. 设计理念
采用模块化设计,各功能模块相互独立、松散耦合,便于维护和扩展。
### 1.1 模块独立性
- 每个功能模块ssq查询、授权码、版本更新独立实现
- 模块之间不直接依赖,通过模块管理器统一管理
- 模块可以独立开发、测试、部署
### 1.2 松耦合设计
- 模块通过接口定义,不依赖具体实现
- 模块管理器负责模块的注册、初始化、启动、停止
- App 层只依赖模块管理器,不直接依赖具体模块
## 2. 模块接口
### 2.1 Module 接口
```go
type Module interface {
Name() string // 模块名称
Init(ctx context.Context) error // 初始化
Start(ctx context.Context) error // 启动
Stop(ctx context.Context) error // 停止
GetAPI() interface{} // 获取 API 接口
}
```
### 2.2 BaseModule 基础实现
提供模块的基础实现,各模块可以继承并扩展。
## 3. 模块管理器
### 3.1 Manager 功能
- 模块注册:`Register(module Module)`
- 模块获取:`Get(name string)`
- 批量操作:`InitAll()`, `StartAll()`, `StopAll()`
- API 收集:`GetAPIs()`
### 3.2 使用方式
```go
manager := module.NewManager()
// 注册模块
ssqModule, _ := module.NewSsqModule()
manager.Register(ssqModule)
authModule, _ := module.NewAuthModule()
manager.Register(authModule)
// 初始化所有模块
manager.InitAll(ctx)
// 启动所有模块
manager.StartAll(ctx)
```
## 4. 功能模块
### 4.1 SSQ 查询模块
**模块名称**: `ssq`
**功能**: 双色球历史数据查询
**API**: `*api.SsqAPI`
**实现文件**: `internal/module/ssq_module.go`
### 4.2 授权码模块
**模块名称**: `auth`
**功能**: 设备授权码验证和激活
**API**: `*api.AuthAPI`
**实现文件**: `internal/module/auth_module.go`
**启动行为**: 自动检查授权状态
### 4.3 版本更新模块
**模块名称**: `update`
**功能**: 版本更新检查、下载、安装
**API**: 待实现
**实现文件**: `internal/module/update_module.go`
**状态**: 占位符,待实现
## 5. 目录结构
```
internal/
├─ module/ # 模块层
│ ├─ module.go # 模块接口定义
│ ├─ manager.go # 模块管理器
│ ├─ ssq_module.go # SSQ 查询模块
│ ├─ auth_module.go # 授权码模块
│ └─ update_module.go # 版本更新模块(待实现)
├─ api/ # API 层
│ ├─ ssq_api.go # SSQ API
│ └─ auth_api.go # Auth API
├─ service/ # 服务层
└─ storage/ # 存储层
```
## 6. 模块扩展
### 6.1 添加新模块
1. 实现 `Module` 接口
2. 创建模块文件(如 `xxx_module.go`
3.`NewApp()` 中注册模块
```go
xxxModule, _ := module.NewXxxModule()
manager.Register(xxxModule)
```
### 6.2 模块生命周期
```
注册 → 初始化 → 启动 → 运行 → 停止
```
- **注册**: 模块创建并注册到管理器
- **初始化**: 模块初始化资源(数据库、配置等)
- **启动**: 模块启动服务(检查授权、启动定时任务等)
- **运行**: 模块提供服务
- **停止**: 模块清理资源
## 7. 优势
### 7.1 可维护性
- 模块独立,修改不影响其他模块
- 清晰的模块边界和职责
### 7.2 可扩展性
- 新增功能只需添加新模块
- 模块可以独立开发和测试
### 7.3 可测试性
- 模块可以独立测试
- 便于 Mock 和单元测试
### 7.4 松耦合
- 模块之间不直接依赖
- 通过接口和管理器解耦
---
> 文档维护者JueChen
> 创建时间2026-01-07

View File

@@ -0,0 +1,247 @@
# 项目架构设计
## 1. 项目概述
ssq-desk 是基于 Wails 框架开发的桌面应用,提供双色球历史数据查询和统计分析功能。
---
## 2. 技术架构
### 2.1 整体架构
```
┌─────────────────────────────────────┐
│ 前端 (Vue 3 + Arco) │
│ ┌────────────┐ ┌──────────────┐ │
│ │ 查询界面 │ │ 结果展示 │ │
│ └────────────┘ └──────────────┘ │
└─────────────────────────────────────┘
↕ Wails Context
┌─────────────────────────────────────┐
│ 后端 (Go + Wails) │
│ ┌──────────┐ ┌──────────────┐ │
│ │ API层 │→ │ Service层 │ │
│ └──────────┘ └──────────────┘ │
│ ↓ │
│ ┌──────────────────────────────┐ │
│ │ Storage层 (Repository) │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ 数据层 │
│ ┌──────────┐ ┌──────────────┐ │
│ │ MySQL │ │ SQLite │ │
│ │ (远程) │ │ (本地缓存) │ │
│ └──────────┘ └──────────────┘ │
└─────────────────────────────────────┘
```
### 2.2 模块化架构
```
┌─────────────────────────────────────┐
│ App (app.go) │
│ 模块管理器统一管理 │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Module Manager │
│ ┌────────┐ ┌────────┐ ┌──────┐ │
│ │ SSQ │ │ Auth │ │Update│ │
│ │ Module │ │ Module │ │Module│ │
│ └────────┘ └────────┘ └──────┘ │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ API Layer │
│ ┌────────┐ ┌────────┐ │
│ │SSQ API │ │Auth API│ │
│ └────────┘ └────────┘ │
└─────────────────────────────────────┘
```
### 2.3 目录结构
```
ssq-desk/
├─ app.go # Wails 应用结构,模块管理器
├─ main.go # 应用入口
├─ internal/ # 内部包
│ ├─ module/ # 模块层(新增)
│ │ ├─ module.go # 模块接口定义
│ │ ├─ manager.go # 模块管理器
│ │ ├─ ssq_module.go # SSQ 查询模块
│ │ ├─ auth_module.go # 授权码模块
│ │ └─ update_module.go # 版本更新模块
│ ├─ api/ # API 层Wails 绑定接口)
│ │ ├─ ssq_api.go # 双色球查询 API
│ │ └─ auth_api.go # 授权码 API
│ ├─ service/ # 业务逻辑层
│ │ ├─ query_service.go # 查询服务
│ │ └─ auth_service.go # 授权服务
│ ├─ storage/ # 存储层
│ │ ├─ models/ # 数据模型
│ │ │ ├─ ssq_history.go # 历史数据模型
│ │ │ └─ authorization.go # 授权模型
│ │ └─ repository/ # 数据访问层
│ │ ├─ mysql_repo.go # MySQL 仓库
│ │ ├─ sqlite_repo.go # SQLite 仓库
│ │ └─ auth_repository.go # 授权仓库
│ └─ database/ # 数据库连接
│ ├─ mysql.go # MySQL 连接
│ └─ sqlite.go # SQLite 连接
├─ web/ # 前端目录
│ ├─ src/
│ │ ├─ views/ # 页面组件
│ │ │ └─ query/ # 查询页面
│ │ ├─ components/ # 公共组件
│ │ └─ wailsjs/ # Wails 生成的文件
│ └─ package.json
└─ wails.json # Wails 配置
```
---
## 3. 数据流设计
### 3.1 查询流程
```
用户输入查询条件
前端调用 Wails 绑定方法
API 层接收参数
Service 层处理业务逻辑
Repository 层查询数据
├─ 优先查询 SQLite本地缓存
└─ 如需更新,查询 MySQL远程
返回结果给前端
前端展示结果(分类统计 + 详细列表)
```
### 3.2 数据同步流程
```
应用启动/手动触发
检查远程数据更新
增量同步(基于 issue_number
写入 SQLite 本地缓存
更新同步状态和日志
```
---
## 4. 核心功能模块
### 4.1 模块化设计
采用模块化架构,各功能模块相互独立、松散耦合:
- **模块接口**:统一的 `Module` 接口
- **模块管理器**:统一管理模块的注册、初始化、启动、停止
- **模块独立性**:各模块可独立开发、测试、部署
详细设计参见:[模块化架构设计](./模块化架构设计.md)
### 4.2 SSQ 查询模块
- **模块名称**`ssq`
- **功能**:根据红球、蓝球条件查询历史数据
- **输入**6个红球 + 1个蓝球 + 蓝球筛选范围
- **输出**:分类统计 + 详细记录列表
- **特性**:支持部分匹配、颜色标识匹配结果
### 4.3 授权码模块
- **模块名称**`auth`
- **功能**:设备授权码验证、激活状态管理
- **存储**SQLite 本地数据库
- **启动行为**:应用启动时自动检查授权状态
### 4.4 版本更新模块
- **模块名称**`update`
- **功能**:版本更新检查、下载、安装
- **状态**:待实现
### 4.5 数据同步模块(待模块化)
- **功能**MySQL 与 SQLite 数据同步
- **策略**:增量同步、全量同步、手动刷新
- **错误处理**:网络异常、数据冲突处理
---
## 5. 性能优化策略
### 5.1 数据查询
- **本地优先**:优先使用 SQLite 本地缓存
- **分页加载**:查询结果分页显示
- **索引优化**:为常用查询字段建立索引
### 5.2 数据同步
- **异步同步**:后台异步同步,不阻塞主流程
- **增量更新**:仅同步增量数据,减少网络传输
- **智能调度**:根据数据更新频率调整同步策略
### 5.3 前端优化
- **虚拟滚动**:大量数据使用虚拟滚动
- **懒加载**:按需加载详细数据
- **缓存策略**:合理使用 LocalStorage 缓存
---
## 6. 安全设计
### 6.1 数据安全
- **连接加密**MySQL 连接使用 TLS/SSL
- **密码管理**:数据库密码加密存储
- **本地数据**:敏感数据本地加密存储
### 6.2 授权验证
- **设备绑定**:授权码与设备绑定
- **激活验证**:启动时验证激活状态
- **离线模式**:授权失效时限制功能
---
## 7. 错误处理
### 7.1 网络错误
- **连接失败**:自动切换到离线模式
- **超时处理**:设置合理的超时时间
- **重试机制**:自动重试失败的请求
### 7.2 数据错误
- **数据校验**:入库前校验数据完整性
- **异常处理**:捕获并记录异常日志
- **用户提示**:友好的错误提示信息
---
## 8. 扩展性设计
### 8.1 功能扩展
- **模块化设计**:功能模块独立,易于扩展(已实现)
- 新增功能只需实现 `Module` 接口并注册
- 模块之间不直接依赖,通过管理器解耦
- 支持模块的独立开发、测试、部署
- **接口抽象**:定义清晰的接口,便于替换实现
- **配置驱动**:支持配置文件扩展功能
### 8.2 数据扩展
- **多数据源**:支持多种数据源接入
- **数据格式**:支持多种数据格式导入
- **统计分析**:预留统计分析扩展接口
---
> 文档维护者JueChen
> 创建时间2026-01-07

View File

@@ -0,0 +1 @@
# 业务模块目录

View File

@@ -0,0 +1,132 @@
# 双色球查询业务模块
## 1. 业务概述
提供双色球历史开奖数据查询功能,支持按红球、蓝球条件查询,并展示匹配结果的分类统计和详细记录。
---
## 2. 业务规则
### 2.1 查询规则
#### 红球规则
- 红球范围1-33
- 输入数量:可输入 0-6 个红球
- 匹配方式完全匹配必须6个红球全部在查询条件中
#### 蓝球规则
- 蓝球范围1-16
- 输入方式:单个蓝球输入框
- 筛选方式:复选框筛选范围(可多选)
#### 查询逻辑
- **完全匹配**:查询结果必须包含所有输入的红球和蓝球
- **部分匹配**:支持只输入部分红球(匹配到即显示)
- **蓝球筛选**:根据勾选的蓝球范围进行过滤
### 2.2 匹配统计规则
#### 统计分类
- 6个红球 + 1个蓝球完全匹配
- 6个红球红球全部匹配蓝球不匹配
- 5个红球 + 1个蓝球5个红球匹配且蓝球匹配
- 5个红球5个红球匹配蓝球不匹配
- ... 依此类推到 0个红球
#### 匹配计数
- 每条历史记录根据匹配情况归类到对应分类
- 统计每个分类的出现次数
- 支持扩展显示低匹配度结果≤3个红球
---
## 3. 数据展示规则
### 3.1 颜色标识
- **匹配的红球**:红色显示(#F53F3F
- **匹配的蓝球**:蓝色显示(#165DFF
- **未匹配的数字**:黑色显示(默认)
### 3.2 结果分类展示
#### 左侧汇总列表
- 显示各匹配级别的统计次数
- 每个汇总项提供 `[显示历史开奖]` 链接
- 点击链接,右侧显示对应的详细记录
#### 右侧详情列表
- 显示期号、红球号码、蓝球号码
- 支持扩展查询:查看前后 n 期数据
- 支持扩展显示:显示低匹配度结果
---
## 4. 数据来源
### 4.1 远程数据库MySQL
- **数据表**`ssq_history`
- **数据内容**:完整历史开奖数据
- **更新频率**:定期更新(新增期号)
### 4.2 本地缓存SQLite
- **数据来源**:从 MySQL 同步
- **用途**:离线查询、快速查询
- **同步策略**:增量同步、手动刷新
---
## 5. 业务场景
### 5.1 查询场景
1. **完整查询**输入6个红球+1个蓝球查看完全匹配记录
2. **部分查询**:输入部分红球,查看匹配情况
3. **统计分析**:查看历史中不同匹配级别的出现频率
### 5.2 数据维护场景
1. **数据同步**:从远程数据库同步最新数据
2. **离线使用**:本地缓存数据,支持离线查询
3. **数据备份**:导出离线数据包,备份数据
---
## 6. 业务流程
### 6.1 查询流程
```
用户输入查询条件
验证输入有效性(球号范围、数量)
执行查询(本地优先)
匹配结果并分类统计
展示汇总列表(左侧)
用户点击汇总项
展示详细记录(右侧)
```
### 6.2 数据同步流程
```
触发同步(启动/手动/定时)
连接远程数据库
检查数据更新(基于 issue_number
增量同步新数据
更新本地 SQLite
记录同步日志
```
---
> 文档维护者JueChen
> 创建时间2026-01-07

View File

@@ -0,0 +1 @@
# 功能迭代目录

View File

@@ -0,0 +1,215 @@
# 双色球查询功能需求文档
## 1. 功能概述
双色球桌面查询应用,提供历史开奖数据查询、统计分析等功能。
---
## 2. 查询功能
### 2.1 查询条件
#### 2.1.1 红球输入
- 6个红色球输入框依次为
- 红球1
- 红球2
- 红球3
- 红球4
- 红球5
- 红球6
#### 2.1.2 蓝球输入
- 1个蓝色球输入框蓝球
#### 2.1.3 蓝球筛选
- 17个复选框
- 蓝球1 至 蓝球1616个选项
- 全选复选框1个
#### 2.1.4 操作按钮
- **查询按钮**:执行查询
- **重置按钮**:清空所有输入,默认勾选全选复选框
### 2.2 查询逻辑
- 根据输入的6个红球和1个蓝球进行匹配查询
- 支持部分匹配(如只输入部分红球)
- 蓝球筛选:根据勾选的蓝球范围进行过滤
### 2.3 查询结果展示
#### 2.3.1 结果列表
显示字段:
- 期数
- 红球1
- 红球2
- 红球3
- 红球4
- 红球5
- 红球6
- 蓝球
#### 2.3.2 数字颜色标识
- **匹配的红球**:红色数字显示
- **匹配的蓝球**:蓝色数字显示
- **未匹配的数字**:黑色数字显示
#### 2.3.3 查询结果分类
**左侧汇总区域**
- 开出过6个红球与1个蓝球X次
- 开出过6个红球X次
- 开出过5个红球与1个蓝球X次
- 开出过5个红球X次
- 开出过4个红球与1个蓝球X次
- 开出过4个红球X次
- 开出过3个红球与1个蓝球X次
- 开出过3个红球X次
- 开出过2个红球与1个蓝球X次
- 开出过2个红球X次
- 开出过1个红球与1个蓝球X次
- 开出过1个红球X次
- 开出过0个红球与1个蓝球X次
- 开出过0个红球X次
- 每个汇总项提供 `[显示历史开奖]` 链接
**右侧详情区域**
- 点击左侧汇总项的 `[显示历史开奖]`,右侧显示对应的详细开奖记录
- 每条记录显示:期号、红球号码、蓝球号码
- 支持扩展查询:`[再扩展查询对比结果上下n期]` 按钮
**扩展功能**
- 底部提供:`[扩展显示≤3个红球的对比结果]` 按钮
---
## 3. 数据维护功能
### 3.1 数据同步
- 从远程数据库同步历史数据
- 支持增量更新
- 数据校验和去重
### 3.2 数据管理
- 查看本地数据统计
- 手动刷新数据
- 数据备份与恢复
---
## 4. 其他功能
### 4.1 版本更新
- 检查更新
- 自动/手动更新
- 更新日志展示
### 4.2 离线数据
- 离线数据包管理
- 离线数据包更新
- 数据包下载与导入
### 4.3 授权管理
- 设备授权码管理
- 激活状态验证
- 授权信息显示
---
## 5. 数据库设计
### 5.1 数据库信息
- **地址**39.99.243.191:3306
- **账号**u_ssq
- **密码**u_ssq@260106
- **数据库名**ssq_dev
### 5.2 数据表结构
#### ssq_history双色球历史开奖数据表
```sql
CREATE TABLE IF NOT EXISTS `ssq_history` (
`id` INT NOT NULL COMMENT '主键ID',
`issue_number` VARCHAR(20) NOT NULL COMMENT '期号如2025145',
`open_date` DATE NULL COMMENT '开奖日期(允许为空)',
`red_ball_1` TINYINT NOT NULL COMMENT '红球1',
`red_ball_2` TINYINT NOT NULL COMMENT '红球2',
`red_ball_3` TINYINT NOT NULL COMMENT '红球3',
`red_ball_4` TINYINT NOT NULL COMMENT '红球4',
`red_ball_5` TINYINT NOT NULL COMMENT '红球5',
`red_ball_6` TINYINT NOT NULL COMMENT '红球6',
`blue_ball` TINYINT NOT NULL COMMENT '蓝球',
`created_at` DATETIME NOT NULL COMMENT '创建时间',
`updated_at` DATETIME NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='双色球历史开奖数据';
```
#### 字段说明
- `id`:主键,唯一标识
- `issue_number`:期号,格式如 "2025145"
- `open_date`:开奖日期,可为空
- `red_ball_1``red_ball_6`6个红球号码1-33
- `blue_ball`蓝球号码1-16
- `created_at`:记录创建时间
- `updated_at`:记录更新时间
---
## 6. 界面设计要求
### 6.1 布局
- 顶部:查询条件区域
- 中间左侧:查询汇总列表
- 中间右侧:查询结果详情
- 底部:扩展功能按钮
### 6.2 样式规范
- 使用 Arco Design 组件库
- 红球数字:红色标识(#F53F3F
- 蓝球数字:蓝色标识(#165DFF
- 未匹配数字:黑色(默认)
- 保持主题兼容性
---
## 7. 技术实现
### 7.1 技术栈
- **前端**Vue 3 + Arco Design + TypeScript
- **后端**Go + Wails
- **数据库**MySQL远程+ SQLite本地缓存
### 7.2 数据存储策略
- 远程 MySQL完整历史数据
- 本地 SQLite缓存查询结果离线支持
### 7.3 性能优化
- 分页加载查询结果
- 本地数据缓存
- 异步数据同步
---
## 8. 开发优先级
### Phase 1核心查询功能
1. 查询条件界面
2. 基础查询功能
3. 结果展示(列表+分类)
### Phase 2数据管理
1. 数据同步功能
2. 本地数据管理
### Phase 3其他功能
1. 版本更新
2. 离线数据包
3. 授权管理
---
> 文档维护者JueChen
> 创建时间2026-01-07

View File

@@ -0,0 +1,114 @@
# 授权码功能设计
## 1. 功能概述
授权码功能用于设备激活和授权验证,确保应用在授权设备上使用。
## 2. 核心功能
### 2.1 设备标识
- 基于主机名、用户目录、操作系统生成设备ID
- 使用 MD5 哈希确保唯一性和稳定性
- 设备ID与授权码绑定
### 2.2 授权码验证
- 授权码输入和格式验证
- 设备绑定授权码与设备ID关联
- 授权状态存储SQLite 本地数据库)
### 2.3 激活验证
- 应用启动时自动验证授权状态
- 授权信息展示
- 授权失效处理
## 3. 数据模型
### 3.1 Authorization 表结构
```sql
CREATE TABLE IF NOT EXISTS `sys_authorization_code` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT, -- 主键ID
`license_code` VARCHAR(100) NOT NULL, -- 授权码(唯一)
`device_id` VARCHAR(100) NOT NULL, -- 设备IDMD5哈希
`activated_at` DATETIME NOT NULL, -- 激活时间
`expires_at` DATETIME NULL, -- 过期时间可选NULL表示永不过期
`status` TINYINT NOT NULL DEFAULT 1, -- 状态1:有效 0:无效)
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 创建时间
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 更新时间
UNIQUE (`license_code`) -- 授权码唯一索引
);
```
### 3.2 字段说明
- `id`: 主键ID自增
- `license_code`: 授权码唯一索引UNIQUE约束
- `device_id`: 设备ID基于主机名、用户目录、操作系统生成的MD5哈希通过 GORM 标签定义索引
- `activated_at`: 激活时间,必填
- `expires_at`: 过期时间可选NULL表示永不过期
- `status`: 状态1:有效 0:无效默认值为1
- `created_at`: 创建时间,自动设置为当前时间
- `updated_at`: 更新时间,自动更新为当前时间
### 3.3 索引说明
- `license_code`: 唯一索引UNIQUE约束用于快速查找和验证授权码
- 其他字段的索引通过 GORM 标签在模型定义中声明,由 GORM AutoMigrate 自动创建
## 4. API 接口
### 4.1 ActivateLicense
- **功能**: 激活授权码
- **参数**: `licenseCode string`
- **返回**: 激活结果和授权状态
### 4.2 GetAuthStatus
- **功能**: 获取当前授权状态
- **返回**: 授权状态信息
### 4.3 GetDeviceID
- **功能**: 获取设备ID
- **返回**: 设备ID字符串
## 5. 实现架构
### 5.1 分层结构
```
API 层 (auth_api.go)
Service 层 (auth_service.go)
Repository 层 (auth_repository.go)
Model 层 (authorization.go)
SQLite 数据库
```
### 5.2 启动验证流程
```
应用启动
初始化 SQLite
初始化 AuthAPI
检查授权状态
未激活 → 提示用户激活
已激活 → 继续运行
```
## 6. 使用说明
### 6.1 激活授权
前端调用 `ActivateLicense(licenseCode)` 方法激活授权码。
### 6.2 检查状态
前端调用 `GetAuthStatus()` 方法获取授权状态,根据状态决定是否显示激活界面。
### 6.3 设备ID
调用 `GetDeviceID()` 获取设备ID可用于授权码生成或问题排查。
---
> 文档维护者JueChen
> 创建时间2026-01-07

View File

@@ -0,0 +1,7 @@
{
"version": "0.1.1",
"download_url": "https://img.1216.top/ssq/releases/ssq-desk-0.1.1.exe",
"changelog": "更新日志内容",
"force_update": false,
"release_date": "2026-01-07"
}

View File

@@ -0,0 +1,347 @@
# 版本更新功能任务规划
> 设计文档请参考:[版本更新设计.md](./版本更新设计.md)
---
## 2. 任务分解与实现检查
### 2.1 版本号管理
#### T2.1.1 版本号定义和解析
- **任务描述**:定义版本号格式和解析逻辑
- **技术要点**
- 版本号格式:`v1.0.0``1.0.0`(语义化版本)
- 版本号比较逻辑(主版本号.次版本号.修订号)
- 当前版本号读取(从 `wails.json` 或编译时注入)
- **依赖**:无
- **优先级**P0
- **实现状态**:✅ 已实现
- **实现位置**`internal/service/version.go`
- **检查结果**
- ✅ 版本号解析:`ParseVersion()` 支持 `v1.0.0``1.0.0` 格式
- ✅ 版本比较:`Version.Compare()` 实现语义化版本比较
#### T2.1.2 版本信息存储
- **任务描述**:本地存储版本信息
- **技术要点**
- 当前版本号存储
- 上次检查更新时间记录
- 更新检查配置(自动检查开关)
- **依赖**T2.1.1
- **优先级**P0
- **实现状态**:✅ 已实现
- **实现位置**`internal/service/update_config.go`
### 2.2 更新检查功能
#### T2.2.1 远程版本检查服务
- **任务描述**:实现远程版本检查接口
- **技术要点**
- 远程版本信息接口JSON 格式)
- 版本号比较逻辑
- 网络请求和错误处理
- 超时控制
- **依赖**T2.1.1
- **优先级**P0
- **实现状态**:✅ 已实现
- **实现位置**`internal/service/update_service.go`
- **检查结果**
- ✅ 远程版本信息接口:`fetchRemoteVersionInfo()` 通过 HTTP GET 获取 JSON
- ✅ 检查频率控制:`UpdateConfig.ShouldCheckUpdate()` 基于时间间隔判断
#### T2.2.2 更新检查触发机制
- **任务描述**:实现更新检查触发方式
- **技术要点**
- **启动时检查**:应用启动时立即检查一次(已实现)
- **自动检查**:根据配置的检查间隔自动检查(已实现)
- **手动检查**:用户手动触发检查更新按钮
- **检查频率控制**:避免频繁请求,支持可配置间隔(已实现)
- **依赖**T2.2.1
- **优先级**P0
- **实现状态**:✅ 已实现
- **实现位置**`internal/module/update_module.go`
- **检查结果**
- ✅ 启动检查:`update_module.go``Start()` 方法中触发检查
#### T2.2.3 更新提示界面
- **任务描述**:前端展示更新提示
- **技术要点**
- 发现新版本时弹窗提示
- 显示当前版本和最新版本号
- 更新日志预览
- 立即更新/稍后提醒按钮
- **依赖**T2.2.2
- **优先级**P0
- **实现状态**:✅ 已实现
- **实现位置**`web/src/composables/useVersion.ts`
### 2.3 更新下载功能
#### T2.3.1 更新包下载服务
- **任务描述**:实现更新包下载逻辑
- **技术要点**
- 更新包下载 URL 获取
- 文件下载(支持断点续传)
- 下载进度计算和回调
- 下载文件校验MD5/SHA256
- **依赖**T2.2.1
- **优先级**P0
- **实现状态**:✅ 已实现
- **实现位置**`internal/service/update_download.go`
- **检查结果**
- ✅ 断点续传:使用 HTTP `Range` 头,支持从断点继续下载
- ✅ 文件完整性验证:`calculateFileHashes()` 计算 MD5 和 SHA256
- ✅ 下载目录管理:使用 `~/.ssq-desk/downloads` 目录
- **代码优化**2026-01-08
- ✅ 提取 `getRemoteFileSize()` 函数,消除重复的 HEAD 请求逻辑
- ✅ 提取 `normalizeProgress()` 函数,统一进度值标准化
- ✅ 精简日志输出,仅保留关键错误日志
- ✅ 代码量减少约 45%
#### T2.3.2 下载进度展示
- **任务描述**:前端展示下载进度
- **技术要点**
- 下载进度条显示
- 下载速度显示
- 下载状态提示(下载中/暂停/完成/失败)
- 进度值安全控制(确保在 0-100% 之间)
- **依赖**T2.3.1
- **优先级**P0
- **实现状态**:✅ 已实现2026-01-08 优化:添加多层进度值保护)
- **实现位置**`web/src/composables/useVersion.ts`
- **检查结果**
- ✅ 进度反馈:`DownloadProgress` 回调函数,每 0.3 秒更新一次
- ✅ 进度值控制:多层防护确保进度值严格在 0-100% 之间
- 后端 `normalizeProgress()` 函数标准化进度值
- API 层最后一道防线检查
- 前端 `clampProgress()` 函数确保显示值合法
- **代码优化**2026-01-08
- ✅ 提取 `clampProgress()` 辅助函数,统一进度值标准化
- ✅ 提取 `resetDownloadState()` 函数,消除重复代码
- ✅ 提取 `installUpdate()` 函数,简化安装逻辑
- ✅ 代码量从 485 行减少到约 300 行(减少约 40%
### 2.4 更新安装功能
#### T2.4.1 更新包安装逻辑
- **任务描述**:实现更新包安装
- **技术要点**
- 更新包解压/安装
- 安装前备份当前版本
- 安装后重启应用
- 安装失败回滚机制
- **依赖**T2.3.1
- **优先级**P0
- **实现状态**:✅ 已实现
- **实现位置**`internal/service/update_install.go`
- **检查结果**
- ✅ 安装前备份:`BackupApplication()` 在安装前创建备份
- ✅ 安装失败回滚:`rollbackFromBackup()` 在安装失败时恢复备份
- ✅ 多格式支持:支持 `.exe``.zip` 两种格式
- ✅ 自动重启:`restartApplication()` 支持 Windows/macOS/Linux
- **潜在问题**
- ⚠️ Windows 下替换正在运行的可执行文件:当前实现使用重命名方式(`.old` 后缀),但可能在某些情况下失败
- **代码优化**2026-01-08
- ✅ 移除所有调试日志
- ✅ 代码量减少约 30%
#### T2.4.2 安装方式选择
- **任务描述**:支持自动和手动安装
- **技术要点**
- 自动安装:下载完成后自动安装
- 手动安装:用户确认后安装
- 安装时机选择(立即安装/退出时安装)
- **依赖**T2.4.1
- **优先级**P1
- **实现状态**:✅ 已实现(自动安装)
### 2.5 更新日志展示
#### T2.5.1 更新日志获取
- **任务描述**:获取和解析更新日志
- **技术要点**
- 更新日志接口Markdown 或 HTML 格式)
- 日志版本关联
- 日志内容解析和格式化
- **依赖**T2.2.1
- **优先级**P1
- **实现状态**:✅ 已实现(从版本信息接口获取)
#### T2.5.2 更新日志界面
- **任务描述**:前端展示更新日志
- **技术要点**
- 更新日志弹窗/页面
- Markdown 渲染(如需要)
- 版本历史列表
- 日志内容展示
- **依赖**T2.5.1
- **优先级**P1
- **实现状态**:✅ 已实现(在更新提示对话框中显示)
### 2.6 更新配置管理
#### T2.6.1 更新配置界面
- **任务描述**:更新相关配置管理
- **技术要点**
- 自动检查更新开关
- 检查频率设置
- 更新通道选择(稳定版/测试版)
- **依赖**T2.1.2
- **优先级**P2
- **实现状态**:✅ 已实现(自动检查开关、检查间隔配置)
---
## 3. 实现检查与改进建议
### 3.1 符合行业最佳实践 ✅
- ✅ 版本检查机制:远程接口、语义化版本、检查频率控制
- ✅ 下载机制:断点续传、进度反馈、文件完整性验证
- ✅ 安装机制:备份回滚、多格式支持、自动重启
- ✅ 安全性文件哈希验证、HTTPS 传输、进度值安全
- ⚠️ 数字签名验证:未实现(建议增强)
### 3.2 优势
1. **完整的备份和回滚机制**:安装前备份,失败时自动回滚
2. **多格式支持**:同时支持 `.exe` 安装程序和 `.zip` 压缩包
3. **详细的进度反馈**:实时显示下载进度、速度、大小
4. **灵活的配置管理**:支持自定义检查间隔和检查地址
5. **代码质量**:遵循 DRY 原则,代码简洁易维护
### 3.3 不足与改进建议
#### 高优先级 🔴
1. **添加数字签名验证**
- Windows: 验证 `.exe` 文件的 Authenticode 签名
- macOS: 验证 `.app` 的代码签名
- 在安装前验证签名,确保更新包来源可信
2. **改进 Windows 文件替换机制**
- 使用 `MoveFileEx` API 的 `MOVEFILE_DELAY_UNTIL_REBOOT` 标志
- 或者使用临时文件名 + 原子替换的方式
#### 中优先级 ⚠️
1. **添加下载 URL 白名单验证**
- 在配置中维护允许的下载域名列表
- 下载前验证 URL 是否在白名单中
2. **优化下载超时机制**
- 根据文件大小动态调整超时时间
- 添加网络状态检测,网络断开时暂停下载
3. **添加增量更新支持**
- 服务器提供增量更新包(仅包含差异部分)
- 客户端支持增量更新包的下载和安装
#### 低优先级 💡
1. **添加更新包压缩**
- 服务器提供压缩的更新包(`.zip``.7z`
- 客户端下载后自动解压
2. **优化进度更新频率**
- 将进度更新频率改为可配置
- 根据下载速度动态调整更新频率
---
## 4. 代码优化记录
### 5.1 优化时间线
- **2026-01-08**:代码重构和精简
- 后端代码量减少 40-50%
- 前端代码量减少约 40%485行 → 300行
- 添加多层进度值保护机制
- 遵循 DRY 原则,提高代码质量
### 5.2 后端优化详情
#### update_download.go减少约 45%
- ✅ 提取 `getRemoteFileSize()` 函数,消除重复的 HEAD 请求逻辑
- ✅ 提取 `normalizeProgress()` 函数,统一进度值标准化
- ✅ 移除大量调试日志,仅保留关键错误日志
- ✅ 优化文件大小获取逻辑,支持多种方式获取
#### update_api.go减少约 50%
- ✅ 移除所有不必要的 `log.Printf` 调试日志
- ✅ 简化错误处理逻辑
- ✅ 优化进度回调函数,减少重复检查
#### update_install.go减少约 30%
- ✅ 移除所有调试日志
- ✅ 保留关键错误返回
### 5.3 前端优化详情
#### useVersion.ts减少约 40%
- ✅ 提取 `clampProgress()` 辅助函数,统一进度值标准化
- ✅ 提取 `resetDownloadState()` 函数,消除重复代码
- ✅ 提取 `installUpdate()` 函数,简化安装逻辑
- ✅ 优化事件处理,减少重复的 `nextTick` 调用
- ✅ 遵循 DRY 原则,提高代码可维护性
### 5.4 关键改进
1. **进度值安全**:多层防护确保进度值严格在 0-100% 之间
- 后端 `normalizeProgress()` 函数
- 后端 API 层检查
- 前端 `clampProgress()` 函数
- 组件内 `Math.max(0, Math.min(100, ...))` 限制
2. **代码质量**
- 遵循 DRY 原则,消除重复代码
- 单一职责原则,每个函数只做一件事
- 减少日志输出,提高性能
- 代码更简洁易读,易于维护
---
## 5. 开发顺序建议
### 第一阶段(核心功能)✅ 已完成
1. T2.1.1 → T2.1.2(版本号管理)
2. T2.2.1 → T2.2.2 → T2.2.3(更新检查)
3. T2.3.1 → T2.3.2(更新下载)
4. T2.4.1(更新安装)
### 第二阶段(增强功能)✅ 部分完成
1. T2.4.2(安装方式选择)- ✅ 自动安装已实现
2. T2.5.1 → T2.5.2(更新日志)- ✅ 已实现
### 第三阶段(配置管理)✅ 已完成
1. T2.6.1(更新配置)- ✅ 已实现
---
## 6. 任务优先级说明
- **P0**:核心功能,必须完成(版本检查、下载、安装)- ✅ 已完成
- **P1**:重要功能(安装方式选择、更新日志)- ✅ 已实现
- **P2**:辅助功能(配置管理)- ✅ 已实现
---
## 7. 总结
### 8.1 整体评价
我们的实现**基本符合**官方和行业最佳实践,主要功能都已实现,包括:
- ✅ 版本检查机制
- ✅ 下载机制(含断点续传)
- ✅ 安装机制(含备份和回滚)
- ✅ 前端 UI 和交互
- ✅ 代码优化和重构2026-01-08
### 8.2 主要不足
1. **安全性**:缺少数字签名验证(高优先级)
2. **Windows 兼容性**:文件替换机制可能需要改进(高优先级)
3. **功能增强**:缺少增量更新支持(中优先级)
### 8.3 建议
1. **立即改进**:添加数字签名验证,提高安全性
2. **短期优化**:改进 Windows 文件替换机制,提高成功率
3. **长期规划**:考虑添加增量更新支持,减少下载量
---
> 文档维护者JueChen
> 创建时间2026-01-07
> 最后更新2026-01-08

View File

@@ -0,0 +1,285 @@
# 版本更新功能设计文档
## 1. 功能概述
实现应用版本更新检查、下载、安装功能,支持自动和手动更新,提供更新日志展示。
### 1.1 核心功能
- 版本检查:自动/手动检查远程版本信息
- 更新下载:支持断点续传的更新包下载
- 更新安装:自动备份、安装、重启
- 进度展示:实时显示下载和安装进度
### 1.2 设计原则
- **安全性**文件哈希验证、HTTPS 传输
- **可靠性**:断点续传、备份回滚机制
- **用户体验**:实时进度反馈、错误提示
- **代码质量**:遵循 DRY 原则,简洁易维护
---
## 2. 架构设计
### 2.1 模块划分
```
版本更新模块
├── 后端服务层
│ ├── update_service.go # 版本检查服务
│ ├── update_download.go # 下载服务
│ ├── update_install.go # 安装服务
│ └── update_config.go # 配置管理
├── API 层
│ └── update_api.go # 更新 API 接口
└── 前端层
└── useVersion.ts # 版本管理 Composable
```
### 2.2 数据流
```
前端 (useVersion.ts)
↓ 调用 API
API 层 (update_api.go)
↓ 调用服务
服务层 (update_service.go / update_download.go / update_install.go)
↓ 事件推送
前端 (通过 Wails Events 接收进度)
```
### 2.3 关键组件
#### 版本检查服务
- **职责**:获取远程版本信息,比较版本号
- **输入**:检查 URL
- **输出**:更新信息(版本号、下载地址、更新日志等)
#### 下载服务
- **职责**:下载更新包,计算进度,验证文件
- **输入**:下载 URL
- **输出**:下载结果(文件路径、哈希值)
- **特性**:断点续传、进度回调
#### 安装服务
- **职责**:备份、安装、重启应用
- **输入**:安装包路径
- **输出**:安装结果
- **特性**:自动备份、失败回滚
---
## 3. 技术实现要点
### 3.1 版本号格式
- **格式**:语义化版本 `主版本号.次版本号.修订号`(如 `1.0.0`
- **比较逻辑**:逐级比较主版本号、次版本号、修订号
- **解析支持**:支持 `v1.0.0``1.0.0` 格式
### 3.2 远程版本信息接口
#### 接口地址
```
https://img.1216.top/ssq/last-version.json
```
#### 接口返回格式
```json
{
"version": "0.1.1",
"download_url": "https://img.1216.top/ssq/releases/ssq-desk-0.1.1.exe",
"changelog": "更新日志内容",
"force_update": false,
"release_date": "2026-01-07"
}
```
#### 字段说明
- `version`: 最新版本号(语义化版本)
- `download_url`: 更新包下载地址
- `changelog`: 更新日志内容
- `force_update`: 是否强制更新
- `release_date`: 发布日期
### 3.3 更新包格式
- **Windows**`.exe` 安装包或 `.zip` 压缩包
- **支持方式**:全量更新(当前实现)
### 3.4 下载机制设计
#### 断点续传
- 使用 HTTP `Range` 头实现断点续传
- 支持从已下载位置继续下载
- 自动检测已下载文件大小
#### 进度计算
- **更新频率**:每 0.3 秒更新一次
- **进度值保护**:多层防护确保进度值在 0-100% 之间
- 后端 `normalizeProgress()` 函数标准化
- API 层最后一道防线检查
- 前端 `clampProgress()` 函数确保显示值合法
#### 文件大小获取
1. 优先从 `Content-Range` 头获取(最准确)
2. 从响应 `ContentLength` 获取
3. 通过 HEAD 请求获取(备用方案)
### 3.5 安装机制设计
#### 安装流程
1. **备份**:安装前自动备份当前版本到 `~/.ssq-desk/backups/`
2. **验证**可选的文件哈希验证MD5/SHA256
3. **安装**
- `.exe` 文件:直接替换可执行文件
- `.zip` 文件:解压后替换文件
4. **重启**:安装成功后自动重启应用
5. **回滚**:安装失败时自动恢复备份
#### Windows 文件替换
- 使用重命名方式(`.old` 后缀)
- 如果文件正在使用,将在重启后替换
### 3.6 检查间隔设计
#### 检查触发机制
- **启动时检查**:应用启动时立即检查一次
- **自动检查**:根据配置的检查间隔自动检查
- **手动检查**:用户可随时手动触发检查
#### 推荐配置
- **开发/测试**5-30分钟
- **生产环境**60分钟1小时推荐
- **省流模式**360分钟6小时
- **最小间隔**5分钟防止过于频繁
- **最大间隔**1440分钟24小时
---
## 4. 接口设计
### 4.1 后端 API 接口
#### CheckUpdate()
- **功能**:检查是否有新版本
- **返回**:更新信息(版本号、下载地址、更新日志等)
#### GetCurrentVersion()
- **功能**:获取当前版本号
- **返回**:当前版本号
#### DownloadUpdate(downloadURL)
- **功能**:下载更新包(异步)
- **参数**:下载地址
- **事件**:通过 `download-progress``download-complete` 事件推送进度
#### InstallUpdate(filePath, autoRestart)
- **功能**:安装更新包
- **参数**:文件路径、是否自动重启
- **返回**:安装结果
### 4.2 前端事件
#### download-progress
- **触发时机**:下载过程中(每 0.3 秒)
- **数据格式**
```json
{
"progress": 50.5,
"speed": 1024000,
"downloaded": 5242880,
"total": 10485760
}
```
#### download-complete
- **触发时机**:下载完成或失败
- **数据格式**(成功):
```json
{
"success": true,
"file_path": "C:\\Users\\...\\ssq-desk-0.1.1.exe",
"file_size": 10485760
}
```
- **数据格式**(失败):
```json
{
"error": "下载失败:网络错误"
}
```
---
## 5. 安全设计
### 5.1 已实现的安全措施
-**文件哈希验证**:支持 MD5/SHA256 哈希验证
-**HTTPS 传输**:使用 HTTPS 确保传输安全
-**进度值安全**:多层防护确保进度值不会异常
### 5.2 待增强的安全措施
- ⚠️ **数字签名验证**:未实现(建议增强)
- Windows: 验证 `.exe` 文件的 Authenticode 签名
- macOS: 验证 `.app` 的代码签名
- ⚠️ **URL 白名单验证**:未实现(建议增强)
- 在配置中维护允许的下载域名列表
- 下载前验证 URL 是否在白名单中
---
## 6. 错误处理
### 6.1 网络错误
- **处理方式**:提示用户检查网络连接
- **重试机制**:支持手动重试
### 6.2 下载失败
- **处理方式**:显示错误信息,支持重新下载
- **断点续传**:支持从断点继续下载
### 6.3 安装失败
- **处理方式**:自动回滚到备份版本
- **备份机制**:安装前自动创建备份
### 6.4 进度值异常
- **处理方式**:多层防护确保进度值在 0-100% 之间
- **保护机制**
- 后端标准化函数
- API 层检查
- 前端标准化函数
---
## 7. 性能优化
### 7.1 代码优化2026-01-08
- **后端代码量减少**40-50%
- **前端代码量减少**:约 40%485行 → 300行
- **优化措施**
- 提取重复逻辑为函数
- 精简日志输出
- 优化事件处理
### 7.2 下载优化
- **进度更新频率**0.3 秒(平衡性能和用户体验)
- **缓冲区大小**32KB
- **超时时间**30 分钟
---
## 8. 扩展性设计
### 8.1 可扩展功能
- 增量更新支持
- 更新包压缩
- 多通道更新(稳定版/测试版)
- 数字签名验证
### 8.2 配置化设计
- 检查间隔可配置
- 检查地址可配置
- 自动检查开关可配置
---
> 文档维护者JueChen
> 创建时间2026-01-08

View File

@@ -0,0 +1 @@
# 问题处理目录

149
docs/PROJECT-STATUS.md Normal file
View File

@@ -0,0 +1,149 @@
# 项目开发状态
> 更新时间2026-01-07
## 📊 整体进度
- **总体完成度**23/23100%)✅
- **Phase 1 核心查询功能**11/11100%)✅
- **Phase 2 数据管理功能**6/6100%)✅
- **Phase 3 其他功能**6/6100%)✅
## ✅ 已完成功能
### Phase 1核心查询功能
| 编号 | 任务 | 状态 | 完成时间 |
|------|------|------|----------|
| 101 | 数据库连接模块 | ✅ | 2026-01-07 |
| 102 | 数据模型定义 | ✅ | 2026-01-07 |
| 103 | Repository 层实现 | ✅ | 2026-01-07 |
| 104 | 查询服务实现 | ✅ | 2026-01-07 |
| 105 | 查询结果处理 | ✅ | 2026-01-07 |
| 106 | API 接口定义 | ✅ | 2026-01-07 |
| 107 | API 实现 | ✅ | 2026-01-07 |
| 108 | 查询条件组件 | ✅ | 2026-01-07 |
| 109 | 查询结果展示组件 | ✅ | 2026-01-07 |
| 110 | 交互功能实现 | ✅ | 2026-01-07 |
| 111 | 前端与后端集成 | ✅ | 2026-01-07 |
### Phase 2数据管理功能
| 编号 | 任务 | 状态 | 完成时间 |
|------|------|------|----------|
| 201 | 数据同步服务 | ✅ | 2026-01-07 |
| 202 | 同步触发机制 | ✅ | 2026-01-07 |
| 203 | 同步状态展示 | ✅ | 2026-01-07 |
| 204 | 数据统计功能 | ✅ | 2026-01-07 |
| 205 | 数据刷新功能 | ✅ | 2026-01-07 |
| 206 | 数据备份与恢复 | ✅ | 2026-01-07 |
### Phase 3其他功能
| 编号 | 任务 | 状态 | 完成时间 |
|------|------|------|----------|
| 301 | 更新检查功能 | ✅ | 2026-01-07 |
| 302 | 更新下载和安装 | ✅ | 2026-01-07 |
| 303 | 离线数据包管理 | ✅ | 2026-01-07 |
| 304 | 授权码管理 | ✅ | 2026-01-07 |
| 305 | 激活验证 | ✅ | 2026-01-07 |
## 📁 项目结构
### 后端结构
```
internal/
├─ api/ # API 层Wails 绑定)
│ ├─ ssq_api.go
│ ├─ auth_api.go
│ ├─ update_api.go
│ ├─ sync_api.go
│ ├─ backup_api.go
│ └─ package_api.go
├─ service/ # 业务逻辑层
│ ├─ query_service.go
│ ├─ auth_service.go
│ ├─ update_service.go
│ ├─ sync_service.go
│ ├─ backup_service.go
│ ├─ package_service.go
│ ├─ version.go
│ └─ update_config.go
├─ storage/ # 数据存储层
│ ├─ models/ # 数据模型
│ └─ repository/ # 数据访问
├─ database/ # 数据库连接
└─ module/ # 模块管理
```
### 前端结构
```
web/src/
├─ views/
│ ├─ query/ # 查询功能
│ │ ├─ QueryForm.vue
│ │ ├─ ResultPanel.vue
│ │ └─ QueryPage.vue
│ ├─ auth/ # 授权功能
│ │ ├─ ActivateForm.vue
│ │ ├─ AuthStatus.vue
│ │ └─ AuthPage.vue
│ └─ data/ # 数据管理
│ ├─ SyncPanel.vue
│ ├─ DataStats.vue
│ ├─ BackupPanel.vue
│ └─ PackagePanel.vue
└─ App.vue
```
## 🎯 核心功能
### 1. 双色球查询
- ✅ 6个红球 + 1个蓝球查询
- ✅ 蓝球筛选范围
- ✅ 匹配结果分类统计13种类型
- ✅ 结果颜色标识(匹配红色/蓝色)
- ✅ 点击汇总项查看详情
### 2. 数据管理
- ✅ MySQL 到 SQLite 增量同步
- ✅ 数据统计展示
- ✅ 手动数据刷新
- ✅ 数据备份与恢复
- ✅ 离线数据包管理
### 3. 授权管理
- ✅ 设备ID生成
- ✅ 授权码激活
- ✅ 授权状态验证
- ✅ 启动时自动检查
### 4. 版本更新
- ✅ 版本号管理
- ✅ 远程更新检查
- ✅ 更新包下载
- ✅ 更新包安装
## 🚀 下一步计划
1. **测试阶段**
- 单元测试
- 集成测试
- 用户测试
2. **优化阶段**
- 性能优化
- UI/UX 优化
- 错误处理完善
3. **文档完善**
- API 文档
- 用户手册
- 部署文档
---
> 文档维护者JueChen
> 创建时间2026-01-07

28
docs/README.md Normal file
View File

@@ -0,0 +1,28 @@
# ssq-desk 文档总览
```
docs/
├─00-文档目录 # 文档导航索引
├─01-规范 # 开发/接口/数据库/提交流程
├─02-技术文档 # 架构、部署、调度等技术专题
├─03-业务模块 # 业务说明
├─04-功能迭代 # 需求迭代与方案记录
├─05-问题处理 # 问题排查与复盘
└─TODO-LIST # 工作事项推进日志
```
本目录聚合 ssq-desk双色球桌面应用项目的规范、方案与历史记录。
**项目概述**:基于 Wails + Vue 3 + Arco Design 开发的桌面应用,提供双色球历史数据查询、统计分析等功能。
**技术栈**
- 前端Vue 3 + Arco Design + TypeScript
- 后端Go + Wails
- 数据MySQL远程+ SQLite本地缓存
任何新的需求或技术方案产出后,请同步更新对应子目录,确保信息一致。
---
> 文档维护者JueChen
> 创建时间2026-01-07

466
docs/TODO-LIST.md Normal file
View File

@@ -0,0 +1,466 @@
# TODO-LIST - ssq-desk
## 未来待办
| 编号 | 状态 | 事项 | 优先级 | 计划时间 | 任务添加时间 |
|------|------|------|--------|-----------|---------------|
| 101 | ✅ | 数据库连接模块 | P0 | 2026-01-07 | 2026-01-07 |
| 102 | ✅ | 数据模型定义 | P0 | 2026-01-07 | 2026-01-07 |
| 103 | ✅ | Repository 层实现 | P0 | 2026-01-07 | 2026-01-07 |
| 104 | ✅ | 查询服务实现 | P0 | 2026-01-07 | 2026-01-07 |
| 105 | ✅ | 查询结果处理 | P0 | 2026-01-07 | 2026-01-07 |
| 106 | ✅ | API 接口定义 | P0 | 2026-01-07 | 2026-01-07 |
| 107 | ✅ | API 实现 | P0 | 2026-01-07 | 2026-01-07 |
| 108 | ✅ | 查询条件组件 | P0 | 2026-01-07 | 2026-01-07 |
| 109 | ✅ | 查询结果展示组件 | P0 | 2026-01-07 | 2026-01-07 |
| 110 | ✅ | 交互功能实现 | P0 | 2026-01-07 | 2026-01-07 |
| 111 | ✅ | 前端与后端集成 | P0 | 2026-01-07 | 2026-01-07 |
| 201 | ✅ | 数据同步服务 | P1 | 2026-01-07 | 2026-01-07 |
| 202 | ✅ | 同步触发机制 | P1 | 2026-01-07 | 2026-01-07 |
| 203 | ✅ | 同步状态展示 | P1 | 2026-01-07 | 2026-01-07 |
| 204 | ✅ | 数据统计功能 | P1 | 2026-01-07 | 2026-01-07 |
| 205 | ✅ | 数据刷新功能 | P1 | 2026-01-07 | 2026-01-07 |
| 206 | ✅ | 数据备份与恢复 | P2 | 2026-01-07 | 2026-01-07 |
| 301 | ✅ | 更新检查功能 | P2 | 2026-01-07 | 2026-01-07 |
| 302 | ✅ | 更新下载和安装 | P2 | 2026-01-07 | 2026-01-07 |
| 303 | ✅ | 离线数据包管理 | P2 | 2026-01-07 | 2026-01-07 |
| 304 | ✅ | 授权码管理 | P2 | 2026-01-07 | 2026-01-07 |
| 305 | ✅ | 激活验证 | P2 | 2026-01-07 | 2026-01-07 |
## 任务规划
### 任务 101数据库连接模块
**目标**:实现 MySQL 和 SQLite 数据库连接管理
**实现内容**
- MySQL 连接:使用 GORM 连接远程数据库39.99.243.191:3306
- SQLite 连接:本地数据库初始化,自动创建数据目录(~/.ssq-desk/
- 单例模式管理连接,支持连接复用
**涉及文件**
- `internal/database/mysql.go` - MySQL 连接实现
- `internal/database/sqlite.go` - SQLite 连接实现
### 任务 102数据模型定义
**目标**:定义 `ssq_history` 数据模型结构
**实现内容**
- 使用 GORM 标签定义模型
- 字段映射期号、开奖日期、6个红球、1个蓝球
- 自动时间戳管理CreatedAt、UpdatedAt
**涉及文件**
- `internal/storage/models/ssq_history.go` - 数据模型定义
### 任务 103Repository 层实现
**目标**实现数据访问层MySQL 和 SQLite
**实现内容**
- 定义统一接口 `SsqRepository`
- 实现 MySQL 和 SQLite 两种 Repository
- 支持查询、创建、批量创建等操作
**涉及文件**
- `internal/storage/repository/ssq_repository.go` - 接口定义
- `internal/storage/repository/mysql_repo.go` - MySQL 实现
- `internal/storage/repository/sqlite_repo.go` - SQLite 实现
### 任务 104查询服务实现
**目标**:实现核心查询业务逻辑
**实现内容**
- 红球匹配算法:支持部分匹配,使用集合快速查找
- 蓝球筛选:支持单值和范围筛选
- 匹配结果分类统计13种匹配类型
**涉及文件**
- `internal/service/query_service.go` - 查询服务实现
### 任务 105查询结果处理
**目标**:处理查询结果,生成分类统计和详细列表
**实现内容**
- 匹配度计算:统计匹配的红球数量
- 结果分类按匹配度生成13种类型6红1蓝、6红、5红1蓝等
- 数据格式化:返回结构化结果
**涉及文件**
- `internal/service/query_service.go` - 查询结果处理逻辑
### 任务 106API 接口定义
**目标**:定义 Wails 绑定方法,供前端调用
**实现内容**
- 定义 `QueryRequest` 参数结构体
- 定义 `QueryHistory` 方法接口
- 返回 `QueryResult` 结果结构
**涉及文件**
- `internal/api/ssq_api.go` - API 接口定义
### 任务 107API 实现
**目标**:实现 API 方法,调用 Service 层
**实现内容**
- 参数验证红球范围1-33蓝球范围1-16
- 错误处理:统一错误返回格式
- 结果转换:将 Service 结果转换为前端可用格式
**涉及文件**
- `internal/api/ssq_api.go` - API 实现
- `app.go` - Wails 绑定
### 任务 108查询条件组件
**目标**:实现查询条件输入界面
**实现内容**
- 6个红球输入框数字输入范围1-33
- 1个蓝球输入框数字输入范围1-16
- 16个蓝球筛选复选框 + 全选功能
- 查询按钮、重置按钮
**技术要点**
- 使用 Arco Design 组件InputNumber、Checkbox
- 输入验证和范围限制
- 表单状态管理
**涉及文件**
- `web/src/views/query/QueryForm.vue` - 查询条件组件
### 任务 109查询结果展示组件
**目标**:实现查询结果展示界面
**实现内容**
- 左侧汇总列表13种匹配类型统计
- 右侧详情列表(期号、红球、蓝球)
- 数字颜色标识(匹配红球红色、匹配蓝球蓝色、未匹配黑色)
**技术要点**
- 使用 Arco Design Layout 双栏布局
- 数字颜色样式(红色 #F53F3F、蓝色 #165DFF
- 列表组件和表格组件
**涉及文件**
- `web/src/views/query/ResultPanel.vue` - 结果展示组件
### 任务 110交互功能实现
**目标**:实现查询交互逻辑
**实现内容**
- 点击汇总项显示详细记录
- 扩展查询功能前后n期
- 扩展显示低匹配度结果≤3个红球
**涉及文件**
- `web/src/views/query/QueryPage.vue` - 查询页面主组件
### 任务 111前端与后端集成
**目标**:前端调用 Wails API完成查询流程
**实现内容**
- Wails 绑定方法调用
- 数据传递和格式化
- 错误处理和提示
**技术要点**
- 使用 Wails 生成的 TypeScript 类型
- 异步调用和错误处理
- 加载状态管理
**涉及文件**
- `web/src/views/query/QueryPage.vue` - 主页面组件
- `web/src/wailsjs/go/main/App.js` - Wails 生成的文件
### 任务 301更新检查功能
**目标**:实现版本更新检查
**实现内容**
- ✅ 版本号管理(语义化版本格式 `v1.0.0`
- ✅ 版本号比较逻辑(主版本号.次版本号.修订号)
- ✅ 当前版本号读取(从 `wails.json` 或编译时注入)
- ✅ 远程版本检查接口JSON 格式)
- ✅ 版本号比较和更新判断
- ✅ 更新检查触发机制(应用启动自动检查、手动检查)
- ⏳ 更新提示界面(弹窗提示、版本号对比、更新日志预览)- 前端待实现
**技术要点**
- 远程版本信息接口返回 JSON 格式
- 网络请求和错误处理、超时控制
- 检查频率控制(避免频繁请求)
**参考文档**`docs/04-功能迭代/版本更新/任务规划.md`
**涉及文件**
- `internal/service/version.go` - 版本号工具类
- `internal/service/update_config.go` - 更新配置管理
- `internal/service/update_service.go` - 更新服务层
- `internal/api/update_api.go` - 更新 API
- `internal/module/update_module.go` - 版本更新模块
- `app.go` - Wails 绑定方法
**完成时间**2026-01-07
### 任务 302更新下载和安装
**目标**:实现更新包下载和安装
**实现内容**
- ✅ 更新包下载服务(下载 URL 获取、文件下载)
- ✅ 支持断点续传
- ✅ 下载进度计算和回调
- ✅ 下载文件校验MD5/SHA256
- ⏳ 下载进度展示(进度条、下载速度、状态提示)- 前端待实现
- ✅ 更新包安装逻辑(.exe 安装程序支持)
- ✅ 安装后重启应用
- ⏳ 安装前备份当前版本 - 已实现但未集成到安装流程
- ⏳ ZIP 压缩包安装 - 待实现
- ⏳ 自动/手动安装方式选择 - 部分实现
- ⏳ 更新日志获取和展示Markdown 渲染、版本历史)- 待实现
**技术要点**
- 更新包格式Windows `.exe` 安装包(已实现),`.zip` 压缩包(待实现)
- 支持断点续传
- 错误处理:网络错误、下载失败、安装失败
**参考文档**`docs/04-功能迭代/版本更新/任务规划.md`
**涉及文件**
- `internal/service/update_download.go` - 下载服务
- `internal/service/update_install.go` - 安装服务
- `internal/api/update_api.go` - 下载和安装 API
- `app.go` - Wails 绑定方法
**完成时间**2026-01-07
### 任务 304授权码管理
**目标**:实现设备授权码管理
**实现内容**
- 设备标识生成基于主机名、用户目录、操作系统生成设备ID
- 使用 MD5 哈希确保唯一性和稳定性
- 授权码输入和格式验证
- 授权码与设备ID绑定
- 授权状态存储SQLite 本地数据库)
- API 接口:`ActivateLicense``GetAuthStatus``GetDeviceID`
**技术要点**
- 数据模型:`sys_authorization_code`license_code、device_id、activated_at、expires_at、status
- 分层架构API 层 → Service 层 → Repository 层 → Model 层
**参考文档**`docs/04-功能迭代/授权码功能/授权码功能设计.md`
**涉及文件**
- `internal/service/auth_service.go` - 授权服务(已实现)
- `internal/storage/models/authorization.go` - 授权数据模型(已实现)
- `internal/storage/repository/auth_repository.go` - 授权仓库(已实现)
- `internal/api/auth_api.go` - 授权 API已实现
- `internal/module/auth_module.go` - 授权模块(已实现)
- `web/src/views/auth/ActivateForm.vue` - 激活界面(已实现)
- `web/src/views/auth/AuthStatus.vue` - 状态展示(已实现)
- `web/src/views/auth/AuthPage.vue` - 授权页面(已实现)
**完成时间**2026-01-07
**实现亮点**
- 增强授权码格式验证(长度、字符类型验证)
- 设备ID自动生成和绑定
- 前端激活界面和状态展示
- 模块化设计,独立于其他功能
### 任务 305激活验证
**目标**:实现激活状态验证
**实现内容**
- 应用启动时自动验证授权状态(已实现)
- 检查授权状态流程(初始化 SQLite → 初始化 AuthModule → 检查授权状态)
- 未激活时提示用户激活(已实现)
- 已激活时继续运行(已实现)
- 授权信息展示(授权码、激活时间、过期时间、状态)(已实现)
- 授权失效处理(已实现)
- 前端授权状态组件(已实现)
**技术要点**
- 启动验证流程集成到模块管理器
- 授权状态查询和展示
- 前端实时刷新授权状态
**参考文档**`docs/04-功能迭代/授权码功能/授权码功能设计.md`
**完成时间**2026-01-07
## 进行中
| 事项 | 开始时间 | 当前进度 | 预计完成 |
|------|----------|----------|----------|
| 暂无 | - | - | - |
## 今日任务
### 待处理
- [ ] 暂无
### 进行中
- [ ] 暂无
### 已完成
- [x] 任务 101数据库连接模块2026-01-07
- [x] 任务 102数据模型定义2026-01-07
- [x] 任务 103Repository 层实现2026-01-07
- [x] 任务 104查询服务实现2026-01-07
- [x] 任务 105查询结果处理2026-01-07
- [x] 任务 106API 接口定义2026-01-07
- [x] 任务 107API 实现2026-01-07
- [x] 任务 301更新检查功能2026-01-07- 后端完成,前端待实现
- [x] 任务 302更新下载和安装2026-01-07- 后端核心功能完成,部分功能待完善
- [x] 任务 304授权码管理2026-01-07
- [x] 任务 305激活验证2026-01-07
- [x] 任务 108查询条件组件2026-01-07
- [x] 任务 109查询结果展示组件2026-01-07
- [x] 任务 110交互功能实现2026-01-07
- [x] 任务 111前端与后端集成2026-01-07
- [x] 任务 201数据同步服务2026-01-07
- [x] 任务 202同步触发机制2026-01-07
- [x] 任务 203同步状态展示2026-01-07
- [x] 任务 204数据统计功能2026-01-07
- [x] 任务 205数据刷新功能2026-01-07
- [x] 任务 206数据备份与恢复2026-01-07
- [x] 任务 303离线数据包管理2026-01-07
### 备注
- ✅ Phase 1 核心查询功能全部完成101-111
- ✅ Phase 2 数据管理功能全部完成201-206
- ✅ Phase 3 其他功能全部完成301-305
- 🎉 **所有计划任务已完成!项目核心功能开发完成!**
## 历史记录
- 暂无
## 变更迭代
#### 2026-01-07
- **变更**: 初始化项目任务规划
- **说明**:
- 创建任务编号体系101-305
- Phase 1核心查询功能101-111
- Phase 2数据管理201-206
- Phase 3其他功能301-305
- 已完成任务 101-107数据库基础、查询服务、API 层)
- **变更**: 添加版本更新和授权码功能详细规划
- **说明**:
- 创建版本更新功能任务规划文档(`docs/04-功能迭代/版本更新/任务规划.md`
- 创建授权码功能设计文档(`docs/04-功能迭代/授权码功能/授权码功能设计.md`
- 更新任务 301、302、304、305 的详细说明
- 补充参考文档链接
- **变更**: 实现版本更新检查功能(任务 301
- **说明**:
- 完成版本号管理工具(`internal/service/version.go`
- 完成更新配置存储(`internal/service/update_config.go`
- 完成更新服务层(`internal/service/update_service.go`
- 完成更新 API 层(`internal/api/update_api.go`
- 完善更新模块(`internal/module/update_module.go`
-`app.go` 中注册模块并添加 Wails 绑定方法
- 支持版本号解析、比较、远程检查、配置管理
- 前端界面待实现(更新提示、下载进度等)
- **变更**: 实现更新下载和安装功能(任务 302
- **说明**:
- 完成更新包下载服务(`internal/service/update_download.go`
- 支持断点续传
- 下载进度回调
- MD5/SHA256 文件校验
- 完成更新包安装服务(`internal/service/update_install.go`
- Windows .exe 安装程序支持
- 应用重启功能
- 应用备份功能(待集成)
- 在 API 层添加下载和安装方法
-`app.go` 中添加 Wails 绑定方法
- ZIP 压缩包安装和前端界面待实现
- **变更**: 完成授权码功能实现
- **说明**:
- 实现授权码管理后端功能(任务 304
- 实现激活验证功能(任务 305
- 增强授权码格式验证(长度、字符类型)
- 创建前端授权码激活界面(`ActivateForm.vue`
- 创建授权状态展示组件(`AuthStatus.vue`
- 创建授权管理页面(`AuthPage.vue`
- 集成到模块化架构(`auth_module.go`
- 应用启动时自动检查授权状态
- **变更**: 完成 Phase 2 数据管理功能(任务 201-205
- **说明**:
- 实现数据同步服务SyncService增量同步、数据校验、状态记录
- 实现同步触发机制:手动触发、状态查询
- 实现同步状态展示前端组件SyncPanel.vue
- 实现数据统计功能:数据总量、最新期号展示
- 实现数据刷新功能:刷新按钮、进度提示
- Phase 2 数据管理功能基本完成 ✅
- **变更**: 完成数据备份与恢复功能(任务 206
- **说明**:
- 实现数据备份服务BackupServiceZIP 打包、元数据记录
- 实现数据恢复功能:从备份文件恢复、恢复前自动备份
- 实现备份列表管理:列出所有备份文件
- 创建前端备份管理界面BackupPanel.vue
- Phase 2 数据管理功能全部完成 ✅
- **变更**: 完成离线数据包管理功能(任务 303
- **说明**:
- 实现数据包下载服务:从远程 URL 下载 ZIP 数据包
- 实现数据包导入功能:从 ZIP 文件导入 JSON 数据
- 实现更新检查功能:与本地最新期号对比
- 实现本地数据包列表管理
- 创建前端数据包管理界面PackagePanel.vue
- Phase 3 其他功能基本完成 ✅
- **变更**: 项目核心功能开发完成
- **说明**:
- Phase 1核心查询功能 11/11100%)✅
- Phase 2数据管理功能 6/6100%)✅
- Phase 3其他功能 6/6100%)✅
- 总体进度23/23100%)✅
- 🎉 **所有计划任务已完成!**
- **变更**: 完成 Phase 1 前端开发(任务 108-111
- **说明**:
- 实现查询条件组件QueryForm.vue6个红球输入、1个蓝球输入、16个蓝球筛选复选框、查询/重置按钮
- 实现查询结果展示组件ResultPanel.vue左侧汇总列表、右侧详情表格、数字颜色标识
- 实现交互功能:点击汇总项显示详细记录
- 完成前后端集成:调用 Wails API数据传递和错误处理
- Phase 1 核心查询功能全部完成 ✅
- **变更**: 完成数据备份与恢复功能(任务 206
- **说明**:
- 实现数据备份服务BackupServiceZIP 打包、元数据记录
- 实现数据恢复功能:从备份文件恢复、恢复前自动备份
- 实现备份列表管理:列出所有备份文件
- 创建前端备份管理界面BackupPanel.vue
- Phase 2 数据管理功能全部完成 ✅
- **变更**: 完成离线数据包管理功能(任务 303
- **说明**:
- 实现数据包下载服务:从远程 URL 下载 ZIP 数据包
- 实现数据包导入功能:从 ZIP 文件导入 JSON 数据
- 实现更新检查功能:与本地最新期号对比
- 实现本地数据包列表管理
- 创建前端数据包管理界面PackagePanel.vue
- Phase 3 其他功能全部完成 ✅
- **变更**: 项目核心功能开发完成 🎉
- **说明**:
- ✅ Phase 1核心查询功能 11/11100%
- ✅ Phase 2数据管理功能 6/6100%
- ✅ Phase 3其他功能 6/6100%
- ✅ 总体进度23/23100%
- 🎉 **所有计划任务已完成!项目可以进入测试和优化阶段。**
---
> 文档维护者JueChen
> 创建时间2026-01-07
> 最后更新2026-01-07

6
docs/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "docs",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

3
docs/ssq-desk/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
build/bin
node_modules
frontend/dist

19
docs/ssq-desk/README.md Normal file
View File

@@ -0,0 +1,19 @@
# README
## About
This is the official Wails Vanilla template.
You can configure the project by editing `wails.json`. More information about the project settings can be found
here: https://wails.io/docs/reference/project-config
## Live Development
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
to this in your browser, and you can call your Go code from devtools.
## Building
To build a redistributable, production mode package, use `wails build`.

27
docs/ssq-desk/app.go Normal file
View File

@@ -0,0 +1,27 @@
package main
import (
"context"
"fmt"
)
// App struct
type App struct {
ctx context.Context
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// Greet returns a greeting for the given name
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}

View File

@@ -0,0 +1,35 @@
# Build Directory
The build directory is used to house all the build files and assets for your application.
The structure is:
* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files
## Mac
The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.
The directory contains the following files:
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
## Windows
The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}

View File

@@ -0,0 +1,114 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd

View File

@@ -0,0 +1,249 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "{{.Name}}"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
{{range .Info.FileAssociations}}
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
File "..\{{.IconName}}.ico"
{{end}}
!macroend
!macro wails.unassociateFiles
; Delete app associations
{{range .Info.FileAssociations}}
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
Delete "$INSTDIR\{{.IconName}}.ico"
{{end}}
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
{{end}}
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
{{end}}
!macroend

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>ssq-desk</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^3.0.7"
}
}

View File

@@ -0,0 +1,54 @@
#logo {
display: block;
width: 50%;
height: 50%;
margin: auto;
padding: 10% 0 0;
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
background-origin: content-box;
}
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
}
.input-box .btn {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}

View File

@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -0,0 +1,43 @@
import './style.css';
import './app.css';
import logo from './assets/images/logo-universal.png';
import {Greet} from '../wailsjs/go/main/App';
document.querySelector('#app').innerHTML = `
<img id="logo" class="logo">
<div class="result" id="result">Please enter your name below 👇</div>
<div class="input-box" id="input">
<input class="input" id="name" type="text" autocomplete="off" />
<button class="btn" onclick="greet()">Greet</button>
</div>
</div>
`;
document.getElementById('logo').src = logo;
let nameElement = document.getElementById("name");
nameElement.focus();
let resultElement = document.getElementById("result");
// Setup the greet function
window.greet = function () {
// Get name
let name = nameElement.value;
// Check if the input is empty
if (name === "") return;
// Call App.Greet(name)
try {
Greet(name)
.then((result) => {
// Update result with data back from App.Greet()
resultElement.innerText = result;
})
.catch((err) => {
console.error(err);
});
} catch (err) {
console.error(err);
}
};

View File

@@ -0,0 +1,26 @@
html {
background-color: rgba(27, 38, 54, 1);
text-align: center;
color: white;
}
body {
margin: 0;
color: white;
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
src: local(""),
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
}
#app {
height: 100vh;
text-align: center;
}

View File

@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Greet(arg1: string): Promise<string>;

View File

@@ -0,0 +1,7 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1);
}

View File

@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

View File

@@ -0,0 +1,207 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width: number
height: number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;

View File

@@ -0,0 +1,178 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName) {
return window.runtime.EventsOff(eventName);
}
export function EventsOnce(eventName, callback) {
EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}

37
docs/ssq-desk/go.mod Normal file
View File

@@ -0,0 +1,37 @@
module ssq-desk
go 1.23
require github.com/wailsapp/wails/v2 v2.11.0
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.11.0 => D:\Go\go-mod-cache

81
docs/ssq-desk/go.sum Normal file
View File

@@ -0,0 +1,81 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
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/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

36
docs/ssq-desk/main.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
// Create application with options
err := wails.Run(&options.App{
Title: "ssq-desk",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}
}

13
docs/ssq-desk/wails.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "ssq-desk",
"outputfilename": "ssq-desk",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "绝尘",
"email": "237809796@qq.com"
}
}

273
docs/任务规划.md Normal file
View File

@@ -0,0 +1,273 @@
# 任务规划
## 1. Phase 1核心查询功能
### 1.1 数据库基础建设
#### 101 数据库连接模块
- **任务描述**:实现 MySQL 和 SQLite 数据库连接管理
- **技术要点**
- MySQL 连接池管理(远程数据库)
- SQLite 本地数据库初始化
- 连接配置管理
- **依赖**:无
- **优先级**P0
#### 102 数据模型定义
- **任务描述**:定义 `ssq_history` 数据模型结构
- **技术要点**
- Go 结构体定义(对应数据库表)
- 字段验证规则
- 模型方法(查询、插入等)
- **依赖**101
- **优先级**P0
#### 103 Repository 层实现
- **任务描述**实现数据访问层MySQL 和 SQLite
- **技术要点**
- MySQL Repository远程数据查询
- SQLite Repository本地数据查询和写入
- 统一接口抽象
- **依赖**102
- **优先级**P0
### 1.2 查询服务层
#### 104 查询服务实现
- **任务描述**:实现核心查询业务逻辑
- **技术要点**
- 红球匹配算法(支持部分匹配)
- 蓝球筛选逻辑
- 匹配结果分类统计0-6个红球 + 蓝球组合)
- **依赖**103
- **优先级**P0
#### 105 查询结果处理
- **任务描述**:处理查询结果,生成分类统计和详细列表
- **技术要点**
- 匹配度计算
- 结果分类13种匹配类型
- 数据格式化
- **依赖**104
- **优先级**P0
### 1.3 Wails API 层
#### 106 API 接口定义
- **任务描述**:定义 Wails 绑定方法,供前端调用
- **技术要点**
- 查询接口:`QueryHistory(params)`
- 参数结构体封装(红球、蓝球、筛选条件)
- 返回结果结构体
- **依赖**105
- **优先级**P0
#### 107 API 实现
- **任务描述**:实现 API 方法,调用 Service 层
- **技术要点**
- 参数验证
- 错误处理
- 结果返回
- **依赖**106
- **优先级**P0
### 1.4 前端查询界面
#### 108 查询条件组件
- **任务描述**:实现查询条件输入界面
- **技术要点**
- 6个红球输入框数字输入范围1-33
- 1个蓝球输入框数字输入范围1-16
- 16个蓝球筛选复选框 + 全选功能
- 查询按钮、重置按钮
- **依赖**:无
- **优先级**P0
#### 109 查询结果展示组件
- **任务描述**:实现查询结果展示界面
- **技术要点**
- 左侧汇总列表13种匹配类型统计
- 右侧详情列表(期号、红球、蓝球)
- 数字颜色标识(匹配红球红色、匹配蓝球蓝色、未匹配黑色)
- **依赖**108
- **优先级**P0
#### 110 交互功能实现
- **任务描述**:实现查询交互逻辑
- **技术要点**
- 点击汇总项显示详细记录
- 扩展查询功能前后n期
- 扩展显示低匹配度结果≤3个红球
- **依赖**109, 107
- **优先级**P0
#### 111 前端与后端集成
- **任务描述**:前端调用 Wails API完成查询流程
- **技术要点**
- Wails 绑定方法调用
- 数据传递和格式化
- 错误处理和提示
- **依赖**110
- **优先级**P0
---
## 2. Phase 2数据管理
### 2.1 数据同步功能
#### 201 数据同步服务
- **任务描述**:实现 MySQL 到 SQLite 的数据同步逻辑
- **技术要点**
- 增量同步(基于 `issue_number`
- 数据校验和去重
- 同步状态记录
- **依赖**103
- **优先级**P1
#### 202 同步触发机制
- **任务描述**:实现同步触发方式
- **技术要点**
- 应用启动时自动同步
- 手动触发同步
- 定时同步(可选)
- **依赖**201
- **优先级**P1
#### 203 同步状态展示
- **任务描述**:前端展示同步状态和进度
- **技术要点**
- 同步进度条
- 同步日志显示
- 同步结果提示
- **依赖**202
- **优先级**P1
### 2.2 本地数据管理
#### 204 数据统计功能
- **任务描述**:展示本地数据统计信息
- **技术要点**
- 数据总量统计
- 最新期号显示
- 数据更新时间
- **依赖**103
- **优先级**P1
#### 205 数据刷新功能
- **任务描述**:手动刷新本地数据
- **技术要点**
- 刷新按钮
- 刷新进度提示
- 刷新结果反馈
- **依赖**202
- **优先级**P1
#### 206 数据备份与恢复
- **任务描述**:实现数据备份和恢复功能
- **技术要点**
- 导出 SQLite 数据包
- 导入数据包
- 备份文件管理
- **依赖**103
- **优先级**P2
---
## 3. Phase 3其他功能
### 3.1 版本更新
#### 301 更新检查功能
- **任务描述**:实现版本更新检查
- **技术要点**
- 版本号管理(语义化版本格式)
- 远程版本检查接口JSON 格式)
- 版本号比较逻辑
- 更新提示界面
- 检查触发机制(启动检查、手动检查)
- **依赖**:无
- **优先级**P2
- **参考文档**`docs/04-功能迭代/版本更新/任务规划.md`
#### 302 更新下载和安装
- **任务描述**:实现更新包下载和安装
- **技术要点**
- 更新包下载(支持断点续传)
- 下载进度展示
- 文件校验MD5/SHA256
- 自动/手动安装
- 安装前备份和失败回滚
- 更新日志展示
- **依赖**301
- **优先级**P2
- **参考文档**`docs/04-功能迭代/版本更新/任务规划.md`
### 3.2 离线数据包
#### 303 离线数据包管理
- **任务描述**:离线数据包的下载、导入、更新
- **技术要点**
- 数据包下载
- 数据包导入
- 数据包更新检查
- **依赖**206
- **优先级**P2
### 3.3 授权管理
#### 304 授权码管理
- **任务描述**:实现设备授权码管理
- **技术要点**
- 设备标识生成(基于主机名、用户目录、操作系统)
- 授权码输入和格式验证
- 授权码与设备ID绑定
- 授权状态存储SQLite 本地数据库)
- 授权信息查询接口
- **依赖**:无
- **优先级**P2
- **参考文档**`docs/04-功能迭代/授权码功能/授权码功能设计.md`
#### 305 激活验证
- **任务描述**:实现激活状态验证
- **技术要点**
- 应用启动时自动验证授权状态
- 授权信息展示
- 授权失效处理
- 设备ID查询接口
- **依赖**304
- **优先级**P2
- **参考文档**`docs/04-功能迭代/授权码功能/授权码功能设计.md`
---
## 4. 任务优先级说明
- **P0**核心功能必须完成Phase 1 所有任务
- **P1**重要功能Phase 2 主要任务
- **P2**辅助功能Phase 3 所有任务
---
## 5. 开发顺序建议
### 第一阶段(核心功能)
1. 101 → 102 → 103数据库基础
2. 104 → 105查询服务
3. 106 → 107API 层)
4. 108 → 109 → 110 → 111前端界面
### 第二阶段(数据管理)
1. 201 → 202 → 203数据同步
2. 204 → 205数据管理
3. 206数据备份可选
### 第三阶段(其他功能)
1. 301 → 302版本更新
2. 303离线数据包
3. 304 → 305授权管理
---
> 文档维护者JueChen
> 创建时间2026-01-07

52
go.mod Normal file
View File

@@ -0,0 +1,52 @@
module ssq-desk
go 1.24.0
require (
github.com/wailsapp/wails/v2 v2.11.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.0
modernc.org/sqlite v1.42.2
)
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.22.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

138
go.sum Normal file
View File

@@ -0,0 +1,138 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
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/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
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.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

87
internal/api/auth_api.go Normal file
View File

@@ -0,0 +1,87 @@
package api
import (
"ssq-desk/internal/service"
"ssq-desk/internal/storage/repository"
)
// AuthAPI 授权码 API
type AuthAPI struct {
authService *service.AuthService
}
// NewAuthAPI 创建授权码 API
func NewAuthAPI() (*AuthAPI, error) {
repo, err := repository.NewSQLiteAuthRepository()
if err != nil {
return nil, err
}
authService := service.NewAuthService(repo)
return &AuthAPI{
authService: authService,
}, nil
}
// ActivateLicense 激活授权码
func (api *AuthAPI) ActivateLicense(licenseCode string) (map[string]interface{}, error) {
if api.authService == nil {
newAPI, err := NewAuthAPI()
if err != nil {
return nil, err
}
api.authService = newAPI.authService
}
err := api.authService.ValidateLicense(licenseCode)
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, nil
}
status, err := api.authService.CheckAuthStatus()
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, nil
}
return map[string]interface{}{
"success": true,
"message": "激活成功",
"data": status,
}, nil
}
// GetAuthStatus 获取授权状态
func (api *AuthAPI) GetAuthStatus() (map[string]interface{}, error) {
if api.authService == nil {
newAPI, err := NewAuthAPI()
if err != nil {
return nil, err
}
api.authService = newAPI.authService
}
status, err := api.authService.CheckAuthStatus()
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, nil
}
return map[string]interface{}{
"success": true,
"data": status,
}, nil
}
// GetDeviceID 获取设备ID
func (api *AuthAPI) GetDeviceID() (string, error) {
return service.GetDeviceID()
}

View File

@@ -0,0 +1,67 @@
package api
import (
"ssq-desk/internal/service"
)
// BackupAPI 数据备份 API
type BackupAPI struct {
backupService *service.BackupService
}
// NewBackupAPI 创建数据备份 API
func NewBackupAPI() *BackupAPI {
return &BackupAPI{
backupService: service.NewBackupService(),
}
}
// Backup 备份数据
func (api *BackupAPI) Backup() (map[string]interface{}, error) {
result, err := api.backupService.Backup()
if err != nil {
return nil, err
}
return map[string]interface{}{
"backup_path": result.BackupPath,
"file_name": result.FileName,
"file_size": result.FileSize,
"created_at": result.CreatedAt,
}, nil
}
// Restore 恢复数据
func (api *BackupAPI) Restore(backupPath string) (map[string]interface{}, error) {
if err := api.backupService.Restore(backupPath); err != nil {
return nil, err
}
return map[string]interface{}{
"success": true,
"message": "数据恢复成功",
}, nil
}
// ListBackups 列出所有备份
func (api *BackupAPI) ListBackups() (map[string]interface{}, error) {
backups, err := api.backupService.ListBackups()
if err != nil {
return nil, err
}
backupList := make([]map[string]interface{}, len(backups))
for i, backup := range backups {
backupList[i] = map[string]interface{}{
"backup_path": backup.BackupPath,
"file_name": backup.FileName,
"file_size": backup.FileSize,
"created_at": backup.CreatedAt,
}
}
return map[string]interface{}{
"backups": backupList,
"count": len(backupList),
}, nil
}

121
internal/api/package_api.go Normal file
View File

@@ -0,0 +1,121 @@
package api
import (
"ssq-desk/internal/service"
)
// PackageAPI 离线数据包 API
type PackageAPI struct {
packageService *service.PackageService
}
// NewPackageAPI 创建数据包 API
func NewPackageAPI() (*PackageAPI, error) {
packageService, err := service.NewPackageService()
if err != nil {
return nil, err
}
return &PackageAPI{
packageService: packageService,
}, nil
}
// DownloadPackage 下载数据包
func (api *PackageAPI) DownloadPackage(downloadURL string) (map[string]interface{}, error) {
if api.packageService == nil {
newAPI, err := NewPackageAPI()
if err != nil {
return nil, err
}
api.packageService = newAPI.packageService
}
result, err := api.packageService.DownloadPackage(downloadURL, nil)
if err != nil {
return nil, err
}
return map[string]interface{}{
"file_path": result.FilePath,
"file_size": result.FileSize,
"duration": result.Duration,
}, nil
}
// ImportPackage 导入数据包
func (api *PackageAPI) ImportPackage(packagePath string) (map[string]interface{}, error) {
if api.packageService == nil {
newAPI, err := NewPackageAPI()
if err != nil {
return nil, err
}
api.packageService = newAPI.packageService
}
result, err := api.packageService.ImportPackage(packagePath)
if err != nil {
return nil, err
}
return map[string]interface{}{
"imported_count": result.ImportedCount,
"updated_count": result.UpdatedCount,
"error_count": result.ErrorCount,
"duration": result.Duration,
}, nil
}
// CheckPackageUpdate 检查数据包更新
func (api *PackageAPI) CheckPackageUpdate(remoteURL string) (map[string]interface{}, error) {
if api.packageService == nil {
newAPI, err := NewPackageAPI()
if err != nil {
return nil, err
}
api.packageService = newAPI.packageService
}
info, err := api.packageService.CheckPackageUpdate(remoteURL)
if err != nil {
return nil, err
}
if info == nil {
return map[string]interface{}{
"need_update": false,
}, nil
}
return map[string]interface{}{
"need_update": true,
"version": info.Version,
"total_count": info.TotalCount,
"latest_issue": info.LatestIssue,
"package_size": info.PackageSize,
"download_url": info.DownloadURL,
"release_date": info.ReleaseDate,
"checksum": info.CheckSum,
}, nil
}
// ListLocalPackages 列出本地数据包
func (api *PackageAPI) ListLocalPackages() (map[string]interface{}, error) {
if api.packageService == nil {
newAPI, err := NewPackageAPI()
if err != nil {
return nil, err
}
api.packageService = newAPI.packageService
}
packages, err := api.packageService.ListLocalPackages()
if err != nil {
return nil, err
}
return map[string]interface{}{
"packages": packages,
"count": len(packages),
}, nil
}

55
internal/api/ssq_api.go Normal file
View File

@@ -0,0 +1,55 @@
package api
import (
"ssq-desk/internal/service"
"ssq-desk/internal/storage/repository"
)
// SsqAPI 双色球查询 API
type SsqAPI struct {
queryService *service.QueryService
}
// NewSsqAPI 创建双色球查询 API
func NewSsqAPI() (*SsqAPI, error) {
// 优先使用 MySQL数据源
repo, err := repository.NewMySQLSsqRepository()
if err != nil {
// MySQL 连接失败,降级使用本地 SQLite
repo, err = repository.NewSQLiteSsqRepository()
if err != nil {
return nil, err
}
}
queryService := service.NewQueryService(repo)
return &SsqAPI{
queryService: queryService,
}, nil
}
// QueryRequest 查询请求参数
type QueryRequest struct {
RedBalls []int `json:"red_balls"` // 红球列表
BlueBall int `json:"blue_ball"` // 蓝球0表示不限制
BlueBallRange []int `json:"blue_ball_range"` // 蓝球筛选范围
}
// QueryHistory 查询历史数据
func (api *SsqAPI) QueryHistory(req QueryRequest) (*service.QueryResult, error) {
if api.queryService == nil {
// 重新初始化
newAPI, err := NewSsqAPI()
if err != nil {
return nil, err
}
api.queryService = newAPI.queryService
}
return api.queryService.Query(service.QueryRequest{
RedBalls: req.RedBalls,
BlueBall: req.BlueBall,
BlueBallRange: req.BlueBallRange,
})
}

69
internal/api/sync_api.go Normal file
View File

@@ -0,0 +1,69 @@
package api
import (
"ssq-desk/internal/service"
"ssq-desk/internal/storage/repository"
)
// SyncAPI 数据同步 API
type SyncAPI struct {
syncService *service.SyncService
}
// NewSyncAPI 创建数据同步 API
func NewSyncAPI() (*SyncAPI, error) {
// 获取 MySQL 和 SQLite Repository
mysqlRepo, err := repository.NewMySQLSsqRepository()
if err != nil {
return nil, err
}
sqliteRepo, err := repository.NewSQLiteSsqRepository()
if err != nil {
return nil, err
}
syncService := service.NewSyncService(mysqlRepo, sqliteRepo)
return &SyncAPI{
syncService: syncService,
}, nil
}
// Sync 执行数据同步
func (api *SyncAPI) Sync() (map[string]interface{}, error) {
if api.syncService == nil {
newAPI, err := NewSyncAPI()
if err != nil {
return nil, err
}
api.syncService = newAPI.syncService
}
result, err := api.syncService.Sync()
if err != nil {
return nil, err
}
return map[string]interface{}{
"total_count": result.TotalCount,
"synced_count": result.SyncedCount,
"new_count": result.NewCount,
"updated_count": result.UpdatedCount,
"error_count": result.ErrorCount,
"latest_issue": result.LatestIssue,
}, nil
}
// GetSyncStatus 获取同步状态
func (api *SyncAPI) GetSyncStatus() (map[string]interface{}, error) {
if api.syncService == nil {
newAPI, err := NewSyncAPI()
if err != nil {
return nil, err
}
api.syncService = newAPI.syncService
}
return api.syncService.GetSyncStatus()
}

254
internal/api/update_api.go Normal file
View File

@@ -0,0 +1,254 @@
package api
import (
"context"
"encoding/json"
"ssq-desk/internal/service"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// UpdateAPI 版本更新 API
type UpdateAPI struct {
updateService *service.UpdateService
ctx context.Context
}
// NewUpdateAPI 创建版本更新 API
func NewUpdateAPI(checkURL string) (*UpdateAPI, error) {
updateService := service.NewUpdateService(checkURL)
return &UpdateAPI{
updateService: updateService,
}, nil
}
// SetContext 设置上下文(用于事件推送)
func (api *UpdateAPI) SetContext(ctx context.Context) {
api.ctx = ctx
}
// CheckUpdate 检查更新
func (api *UpdateAPI) CheckUpdate() (map[string]interface{}, error) {
if api.updateService == nil {
return map[string]interface{}{
"success": false,
"message": "更新服务未初始化",
}, nil
}
result, err := api.updateService.CheckUpdate()
if err != nil {
errorMsg := err.Error()
if errorMsg == "" {
errorMsg = "未知错误"
}
return map[string]interface{}{
"success": false,
"message": errorMsg,
}, nil
}
if result == nil {
return map[string]interface{}{
"success": false,
"message": "检查更新返回结果为空",
}, nil
}
return map[string]interface{}{
"success": true,
"data": result,
}, nil
}
// GetCurrentVersion 获取当前版本号
func (api *UpdateAPI) GetCurrentVersion() (map[string]interface{}, error) {
version := service.GetCurrentVersion()
// 更新配置中的版本号
if config, err := service.LoadUpdateConfig(); err == nil && config.CurrentVersion != version {
config.CurrentVersion = version
service.SaveUpdateConfig(config)
}
return map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"version": version,
},
}, nil
}
// GetUpdateConfig 获取更新配置
func (api *UpdateAPI) GetUpdateConfig() (map[string]interface{}, error) {
config, err := service.LoadUpdateConfig()
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, nil
}
// 确保版本号是最新的
latestVersion := service.GetCurrentVersion()
if config.CurrentVersion != latestVersion {
config.CurrentVersion = latestVersion
service.SaveUpdateConfig(config)
}
return map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"current_version": config.CurrentVersion,
"last_check_time": config.LastCheckTime.Format("2006-01-02 15:04:05"),
"auto_check_enabled": config.AutoCheckEnabled,
"check_interval_minutes": config.CheckIntervalMinutes,
"check_url": config.CheckURL,
},
}, nil
}
// SetUpdateConfig 设置更新配置
func (api *UpdateAPI) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
config, err := service.LoadUpdateConfig()
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, nil
}
config.AutoCheckEnabled = autoCheckEnabled
config.CheckIntervalMinutes = checkIntervalMinutes
if checkURL != "" {
config.CheckURL = checkURL
// 如果 URL 改变,需要重新创建服务
api.updateService = service.NewUpdateService(checkURL)
}
if err := service.SaveUpdateConfig(config); err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, nil
}
return map[string]interface{}{
"success": true,
"message": "配置保存成功",
}, nil
}
// DownloadUpdate 下载更新包(异步,通过事件推送进度)
func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
if downloadURL == "" {
return map[string]interface{}{
"success": false,
"message": "下载地址不能为空",
}, nil
}
go func() {
progressCallback := func(progress float64, speed float64, downloaded int64, total int64) {
if api.ctx == nil {
return
}
// 确保进度值在 0-100 之间
if progress < 0 {
progress = 0
} else if progress > 100 {
progress = 100
}
progressInfo := map[string]interface{}{
"progress": progress,
"speed": speed,
"downloaded": downloaded,
"total": total,
}
progressJSON, _ := json.Marshal(progressInfo)
runtime.EventsEmit(api.ctx, "download-progress", string(progressJSON))
time.Sleep(10 * time.Millisecond)
}
time.Sleep(100 * time.Millisecond)
result, err := service.DownloadUpdate(downloadURL, progressCallback)
if api.ctx != nil {
if err != nil {
errorInfo := map[string]interface{}{"error": err.Error()}
errorJSON, _ := json.Marshal(errorInfo)
runtime.EventsEmit(api.ctx, "download-complete", string(errorJSON))
} else {
resultInfo := map[string]interface{}{
"success": true,
"file_path": result.FilePath,
"file_size": result.FileSize,
}
resultJSON, _ := json.Marshal(resultInfo)
runtime.EventsEmit(api.ctx, "download-complete", string(resultJSON))
}
}
}()
return map[string]interface{}{
"success": true,
"message": "下载已开始",
}, nil
}
// InstallUpdate 安装更新包
func (api *UpdateAPI) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
return api.InstallUpdateWithHash(installerPath, autoRestart, "", "")
}
// InstallUpdateWithHash 安装更新包(带哈希验证)
func (api *UpdateAPI) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
if installerPath == "" {
return map[string]interface{}{
"success": false,
"message": "安装文件路径不能为空",
}, nil
}
result, err := service.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, nil
}
return map[string]interface{}{
"success": result.Success,
"message": result.Message,
"data": result,
}, nil
}
// VerifyUpdateFile 验证更新文件哈希值
func (api *UpdateAPI) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
if filePath == "" {
return map[string]interface{}{
"success": false,
"message": "文件路径不能为空",
}, nil
}
valid, err := service.VerifyFileHash(filePath, expectedHash, hashType)
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, nil
}
return map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"valid": valid,
},
}, nil
}

View File

@@ -0,0 +1,91 @@
package database
import (
"fmt"
"ssq-desk/internal/storage/models"
"sync"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var (
mysqlDB *gorm.DB
mysqlOnce sync.Once
)
// MySQLConfig MySQL 连接配置
type MySQLConfig struct {
Host string
Port int
User string
Password string
Database string
}
// GetMySQLConfig 获取 MySQL 配置(从配置文件或环境变量)
func GetMySQLConfig() *MySQLConfig {
return &MySQLConfig{
Host: "39.99.243.191",
Port: 3306,
User: "u_ssq",
Password: "u_ssq@260106",
Database: "ssq_dev", // 需要根据实际情况修改数据库名
}
}
// InitMySQL 初始化 MySQL 连接
func InitMySQL() (*gorm.DB, error) {
var err error
mysqlOnce.Do(func() {
config := GetMySQLConfig()
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
config.User, config.Password, config.Host, config.Port, config.Database)
mysqlDB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return
}
// 测试连接
sqlDB, err2 := mysqlDB.DB()
if err2 != nil {
err = err2
return
}
if err2 = sqlDB.Ping(); err2 != nil {
err = err2
return
}
// 自动迁移表结构
err2 = mysqlDB.AutoMigrate(
&models.SsqHistory{},
&models.Authorization{},
&models.Version{},
)
if err2 != nil {
err = fmt.Errorf("MySQL 表迁移失败: %v", err2)
return
}
})
if err != nil {
return nil, fmt.Errorf("MySQL 连接初始化失败: %v", err)
}
return mysqlDB, nil
}
// GetMySQL 获取 MySQL 连接实例
func GetMySQL() *gorm.DB {
if mysqlDB == nil {
db, err := InitMySQL()
if err != nil {
return nil
}
return db
}
return mysqlDB
}

102
internal/database/sqlite.go Normal file
View File

@@ -0,0 +1,102 @@
package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"ssq-desk/internal/storage/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite" // 使用纯 Go 的 SQLite 驱动(不需要 CGO
)
var (
sqliteDB *gorm.DB
sqliteOnce sync.Once
)
// InitSQLite 初始化 SQLite 连接
func InitSQLite() (*gorm.DB, error) {
var err error
sqliteOnce.Do(func() {
// 获取应用数据目录
homeDir, err2 := os.UserHomeDir()
if err2 != nil {
err = fmt.Errorf("获取用户目录失败: %v", err2)
return
}
// 创建数据目录
dataDir := filepath.Join(homeDir, ".ssq-desk")
if err2 := os.MkdirAll(dataDir, 0755); err2 != nil {
err = fmt.Errorf("创建数据目录失败: %v", err2)
return
}
// SQLite 数据库文件路径
dbPath := filepath.Join(dataDir, "ssq.db")
// 直接使用 database/sql 打开连接,确保使用 modernc.org/sqlite纯 Go不需要 CGO
sqlDB, err2 := sql.Open("sqlite", dbPath)
if err2 != nil {
err = fmt.Errorf("SQLite 打开连接失败: %v", err2)
sqliteDB = nil
return
}
// 测试连接
if err2 = sqlDB.Ping(); err2 != nil {
err = fmt.Errorf("SQLite 连接测试失败: %v", err2)
sqlDB.Close()
sqliteDB = nil
return
}
// 使用已打开的 database/sql 连接创建 GORM 实例
// 使用 sqlite.Dialector 并指定连接
sqliteDB, err2 = gorm.Open(sqlite.Dialector{Conn: sqlDB}, &gorm.Config{})
if err2 != nil {
err = fmt.Errorf("SQLite GORM 初始化失败: %v", err2)
sqlDB.Close()
sqliteDB = nil
return
}
// 自动迁移表结构如果表已存在但结构不对AutoMigrate 会尝试修改)
// 如果表结构完全不匹配,可能需要手动删除旧表
err2 = sqliteDB.AutoMigrate(
&models.SsqHistory{},
&models.Authorization{},
&models.Version{},
)
if err2 != nil {
err = fmt.Errorf("SQLite 表迁移失败: %v", err2)
sqliteDB = nil
return
}
})
if err != nil {
return nil, err
}
return sqliteDB, nil
}
// GetSQLite 获取 SQLite 连接实例
// 如果连接未初始化或初始化失败,返回 nil
func GetSQLite() *gorm.DB {
if sqliteDB == nil {
db, err := InitSQLite()
if err != nil {
// 初始化失败,返回 nil
return nil
}
sqliteDB = db
}
return sqliteDB
}

View File

@@ -0,0 +1,62 @@
package module
import (
"context"
"ssq-desk/internal/api"
)
// AuthModule 授权码模块
type AuthModule struct {
BaseModule
authAPI *api.AuthAPI
}
// NewAuthModule 创建授权码模块
func NewAuthModule() (*AuthModule, error) {
// 延迟初始化,等到 Init() 方法调用时再创建 API此时数据库已初始化
return &AuthModule{
BaseModule: BaseModule{
name: "auth",
api: nil, // 延迟初始化
},
authAPI: nil, // 延迟初始化
}, nil
}
// Init 初始化模块
func (m *AuthModule) Init(ctx context.Context) error {
if m.authAPI == nil {
authAPI, err := api.NewAuthAPI()
if err != nil {
return err
}
m.authAPI = authAPI
m.api = authAPI
}
return nil
}
// Start 启动模块(检查授权状态)
func (m *AuthModule) Start(ctx context.Context) error {
if m.authAPI == nil {
return m.Init(ctx)
}
status, err := m.authAPI.GetAuthStatus()
if err == nil {
if data, ok := status["data"].(map[string]interface{}); ok && data != nil {
if isActivated, ok := data["is_activated"].(bool); ok && !isActivated {
println("授权未激活,部分功能可能受限")
} else {
println("授权验证通过")
}
}
}
return nil
}
// AuthAPI 返回 Auth API类型安全
func (m *AuthModule) AuthAPI() *api.AuthAPI {
return m.authAPI
}

119
internal/module/manager.go Normal file
View File

@@ -0,0 +1,119 @@
package module
import (
"context"
"fmt"
"sync"
)
// Manager 模块管理器
type Manager struct {
modules map[string]Module
mu sync.RWMutex
}
// NewManager 创建模块管理器
func NewManager() *Manager {
return &Manager{
modules: make(map[string]Module),
}
}
// Register 注册模块
func (m *Manager) Register(module Module) error {
if module == nil {
return fmt.Errorf("模块不能为空")
}
name := module.Name()
if name == "" {
return fmt.Errorf("模块名称不能为空")
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.modules[name]; exists {
return fmt.Errorf("模块 %s 已存在", name)
}
m.modules[name] = module
return nil
}
// Get 获取模块
func (m *Manager) Get(name string) (Module, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
module, exists := m.modules[name]
return module, exists
}
// GetAll 获取所有模块
func (m *Manager) GetAll() []Module {
m.mu.RLock()
defer m.mu.RUnlock()
modules := make([]Module, 0, len(m.modules))
for _, module := range m.modules {
modules = append(modules, module)
}
return modules
}
// InitAll 初始化所有模块
func (m *Manager) InitAll(ctx context.Context) error {
m.mu.RLock()
defer m.mu.RUnlock()
for name, module := range m.modules {
if err := module.Init(ctx); err != nil {
return fmt.Errorf("初始化模块 %s 失败: %v", name, err)
}
}
return nil
}
// StartAll 启动所有模块
func (m *Manager) StartAll(ctx context.Context) error {
m.mu.RLock()
defer m.mu.RUnlock()
for name, module := range m.modules {
if err := module.Start(ctx); err != nil {
return fmt.Errorf("启动模块 %s 失败: %v", name, err)
}
}
return nil
}
// StopAll 停止所有模块
func (m *Manager) StopAll(ctx context.Context) error {
m.mu.RLock()
defer m.mu.RUnlock()
for name, module := range m.modules {
if err := module.Stop(ctx); err != nil {
return fmt.Errorf("停止模块 %s 失败: %v", name, err)
}
}
return nil
}
// GetAPIs 获取所有模块的 API
func (m *Manager) GetAPIs() []interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
apis := make([]interface{}, 0, len(m.modules))
for _, module := range m.modules {
if api := module.GetAPI(); api != nil {
apis = append(apis, api)
}
}
return apis
}

52
internal/module/module.go Normal file
View File

@@ -0,0 +1,52 @@
package module
import "context"
// Module 模块接口
type Module interface {
// Name 返回模块名称
Name() string
// Init 初始化模块
Init(ctx context.Context) error
// Start 启动模块
Start(ctx context.Context) error
// Stop 停止模块
Stop(ctx context.Context) error
// GetAPI 获取模块的 API 接口(供前端调用)
GetAPI() interface{}
}
// BaseModule 基础模块实现
type BaseModule struct {
name string
api interface{}
}
// Name 返回模块名称
func (m *BaseModule) Name() string {
return m.name
}
// GetAPI 获取模块的 API 接口
func (m *BaseModule) GetAPI() interface{} {
return m.api
}
// Init 初始化模块(默认实现)
func (m *BaseModule) Init(ctx context.Context) error {
return nil
}
// Start 启动模块(默认实现)
func (m *BaseModule) Start(ctx context.Context) error {
return nil
}
// Stop 停止模块(默认实现)
func (m *BaseModule) Stop(ctx context.Context) error {
return nil
}

View File

@@ -0,0 +1,42 @@
package module
import (
"context"
"ssq-desk/internal/api"
)
// SsqModule 双色球查询模块
type SsqModule struct {
BaseModule
ssqAPI *api.SsqAPI
}
// NewSsqModule 创建双色球查询模块
func NewSsqModule() (*SsqModule, error) {
// 延迟初始化,等到 Init() 方法调用时再创建 API此时数据库已初始化
return &SsqModule{
BaseModule: BaseModule{
name: "ssq",
api: nil, // 延迟初始化
},
ssqAPI: nil, // 延迟初始化
}, nil
}
// Init 初始化模块
func (m *SsqModule) Init(ctx context.Context) error {
if m.ssqAPI == nil {
ssqAPI, err := api.NewSsqAPI()
if err != nil {
return err
}
m.ssqAPI = ssqAPI
m.api = ssqAPI
}
return nil
}
// SsqAPI 返回 SSQ API类型安全
func (m *SsqModule) SsqAPI() *api.SsqAPI {
return m.ssqAPI
}

View File

@@ -0,0 +1,94 @@
package module
import (
"context"
"ssq-desk/internal/api"
"ssq-desk/internal/service"
)
// UpdateModule 版本更新模块
type UpdateModule struct {
BaseModule
updateAPI *api.UpdateAPI
checkURL string // 版本检查接口 URL
}
// NewUpdateModule 创建版本更新模块
func NewUpdateModule() (*UpdateModule, error) {
// 从配置文件读取检查 URL
config, err := service.LoadUpdateConfig()
if err != nil {
// 配置加载失败,使用默认值
config = &service.UpdateConfig{}
}
checkURL := config.CheckURL
if checkURL == "" {
// 如果配置中没有,使用默认地址
checkURL = "https://img.1216.top/ssq/last-version.json"
}
updateAPI, err := api.NewUpdateAPI(checkURL)
if err != nil {
return nil, err
}
return &UpdateModule{
BaseModule: BaseModule{
name: "update",
api: updateAPI,
},
updateAPI: updateAPI,
checkURL: checkURL,
}, nil
}
// Init 初始化模块
func (m *UpdateModule) Init(ctx context.Context) error {
if m.updateAPI == nil {
updateAPI, err := api.NewUpdateAPI(m.checkURL)
if err != nil {
return err
}
m.updateAPI = updateAPI
m.api = updateAPI
}
// 设置 context 以便推送事件
if m.updateAPI != nil {
m.updateAPI.SetContext(ctx)
}
return nil
}
// Start 启动模块(检查更新配置,决定是否自动检查)
func (m *UpdateModule) Start(ctx context.Context) error {
if m.updateAPI == nil {
return m.Init(ctx)
}
// 加载配置
config, err := service.LoadUpdateConfig()
if err != nil {
// 配置加载失败不影响启动,只记录日志
return nil
}
// 如果启用了自动检查且满足检查条件,则检查更新
if config.ShouldCheckUpdate() && config.CheckURL != "" {
// 异步检查更新,不阻塞启动流程
go func() {
_, err := m.updateAPI.CheckUpdate()
if err == nil {
// 更新最后检查时间
config.UpdateLastCheckTime()
}
}()
}
return nil
}
// UpdateAPI 返回 Update API类型安全
func (m *UpdateModule) UpdateAPI() *api.UpdateAPI {
return m.updateAPI
}

View File

@@ -0,0 +1,215 @@
package service
import (
"crypto/md5"
"encoding/hex"
"fmt"
"os"
"runtime"
"ssq-desk/internal/database"
"ssq-desk/internal/storage/models"
"ssq-desk/internal/storage/repository"
"time"
"gorm.io/gorm"
)
// AuthService 授权服务
type AuthService struct {
repo repository.AuthRepository
}
// NewAuthService 创建授权服务
func NewAuthService(repo repository.AuthRepository) *AuthService {
return &AuthService{repo: repo}
}
// GetDeviceID 获取设备ID基于硬件信息生成
func GetDeviceID() (string, error) {
var deviceInfo string
// 获取主机名
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
// 获取用户目录
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "unknown"
}
// 组合设备信息
deviceInfo = fmt.Sprintf("%s-%s-%s", hostname, homeDir, runtime.GOOS)
// 生成 MD5 作为设备ID
hash := md5.Sum([]byte(deviceInfo))
deviceID := hex.EncodeToString(hash[:])
return deviceID, nil
}
// ValidateLicenseFormat 验证授权码格式
func ValidateLicenseFormat(licenseCode string) error {
if licenseCode == "" {
return fmt.Errorf("授权码不能为空")
}
// 去除空格和连字符
cleaned := ""
for _, c := range licenseCode {
if c != ' ' && c != '-' {
cleaned += string(c)
}
}
// 格式验证至少16位只包含字母和数字
if len(cleaned) < 16 {
return fmt.Errorf("授权码长度不足至少需要16位字符")
}
if len(cleaned) > 100 {
return fmt.Errorf("授权码长度过长最多100位字符")
}
// 验证字符:只允许字母和数字
for _, c := range cleaned {
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
return fmt.Errorf("授权码只能包含字母和数字")
}
}
return nil
}
// ValidateLicenseFromRemote 从远程数据库验证授权码
func ValidateLicenseFromRemote(licenseCode string) error {
// 获取 MySQL 连接
mysqlDB := database.GetMySQL()
if mysqlDB == nil {
return fmt.Errorf("无法连接远程数据库,无法验证授权码")
}
// 清理授权码(去除空格和连字符),与格式验证保持一致
cleaned := ""
for _, c := range licenseCode {
if c != ' ' && c != '-' {
cleaned += string(c)
}
}
// 查询授权码是否存在且有效(支持原始格式和清理后格式)
var auth models.Authorization
err := mysqlDB.Where("(license_code = ? OR license_code = ?) AND status = ?", licenseCode, cleaned, 1).First(&auth).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("授权码无效或不存在")
}
return fmt.Errorf("验证授权码时发生错误: %v", err)
}
// 检查是否过期
if auth.ExpiresAt != nil && auth.ExpiresAt.Before(time.Now()) {
return fmt.Errorf("授权码已过期")
}
return nil
}
// ValidateLicense 验证授权码
func (s *AuthService) ValidateLicense(licenseCode string) error {
// 格式验证
if err := ValidateLicenseFormat(licenseCode); err != nil {
return err
}
// 从远程数据库验证授权码有效性
if err := ValidateLicenseFromRemote(licenseCode); err != nil {
return err
}
// 获取设备ID
deviceID, err := GetDeviceID()
if err != nil {
return fmt.Errorf("获取设备ID失败: %v", err)
}
// 保存授权信息到本地
auth := &models.Authorization{
LicenseCode: licenseCode,
DeviceID: deviceID,
ActivatedAt: time.Now(),
Status: 1,
}
// 检查是否已存在
existing, err := s.repo.GetByLicenseCode(licenseCode)
if err == nil && existing != nil {
// 更新现有授权
existing.DeviceID = deviceID
existing.ActivatedAt = time.Now()
existing.Status = 1
return s.repo.Update(existing)
}
// 创建新授权
return s.repo.Create(auth)
}
// CheckAuthStatus 检查授权状态
func (s *AuthService) CheckAuthStatus() (*AuthStatus, error) {
deviceID, err := GetDeviceID()
if err != nil {
return nil, fmt.Errorf("获取设备ID失败: %v", err)
}
auth, err := s.repo.GetByDeviceID(deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return &AuthStatus{
IsActivated: false,
Message: "未激活",
}, nil
}
return nil, err
}
// 检查状态
if auth.Status != 1 {
return &AuthStatus{
IsActivated: false,
Message: "授权已失效",
}, nil
}
// 检查过期时间
if auth.ExpiresAt != nil && auth.ExpiresAt.Before(time.Now()) {
return &AuthStatus{
IsActivated: false,
Message: "授权已过期",
}, nil
}
return &AuthStatus{
IsActivated: true,
LicenseCode: auth.LicenseCode,
ActivatedAt: auth.ActivatedAt,
ExpiresAt: auth.ExpiresAt,
Message: "已激活",
}, nil
}
// AuthStatus 授权状态
type AuthStatus struct {
IsActivated bool `json:"is_activated"`
LicenseCode string `json:"license_code,omitempty"`
ActivatedAt time.Time `json:"activated_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Message string `json:"message"`
}
// GetAuthInfo 获取授权信息
func (s *AuthService) GetAuthInfo() (*AuthStatus, error) {
return s.CheckAuthStatus()
}

View File

@@ -0,0 +1,287 @@
package service
import (
"archive/zip"
"encoding/json"
"fmt"
"os"
"path/filepath"
"ssq-desk/internal/database"
"time"
)
// BackupService 数据备份服务
type BackupService struct{}
// NewBackupService 创建备份服务
func NewBackupService() *BackupService {
return &BackupService{}
}
// BackupResult 备份结果
type BackupResult struct {
BackupPath string `json:"backup_path"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
CreatedAt string `json:"created_at"`
}
// Backup 备份 SQLite 数据库
func (s *BackupService) Backup() (*BackupResult, error) {
// 获取 SQLite 数据库路径
appDataDir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("获取用户配置目录失败: %v", err)
}
dbPath := filepath.Join(appDataDir, "ssq-desk", "data", "ssq.db")
// 检查数据库文件是否存在
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, fmt.Errorf("数据库文件不存在: %s", dbPath)
}
// 创建备份目录
backupDir := filepath.Join(appDataDir, "ssq-desk", "backups")
if err := os.MkdirAll(backupDir, 0755); err != nil {
return nil, fmt.Errorf("创建备份目录失败: %v", err)
}
// 生成备份文件名(带时间戳)
timestamp := time.Now().Format("20060102-150405")
backupFileName := fmt.Sprintf("ssq-backup-%s.zip", timestamp)
backupPath := filepath.Join(backupDir, backupFileName)
// 创建 ZIP 文件
zipFile, err := os.Create(backupPath)
if err != nil {
return nil, fmt.Errorf("创建备份文件失败: %v", err)
}
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
// 添加数据库文件到 ZIP
dbFile, err := os.Open(dbPath)
if err != nil {
return nil, fmt.Errorf("打开数据库文件失败: %v", err)
}
defer dbFile.Close()
dbInfo, err := dbFile.Stat()
if err != nil {
return nil, fmt.Errorf("获取数据库文件信息失败: %v", err)
}
dbHeader, err := zip.FileInfoHeader(dbInfo)
if err != nil {
return nil, fmt.Errorf("创建 ZIP 文件头失败: %v", err)
}
dbHeader.Name = "ssq.db"
dbHeader.Method = zip.Deflate
dbWriter, err := zipWriter.CreateHeader(dbHeader)
if err != nil {
return nil, fmt.Errorf("创建 ZIP 写入器失败: %v", err)
}
if _, err := dbWriter.Write([]byte{}); err != nil {
return nil, fmt.Errorf("写入 ZIP 文件失败: %v", err)
}
// 重新写入数据库内容
if _, err := dbFile.Seek(0, 0); err != nil {
return nil, fmt.Errorf("重置文件指针失败: %v", err)
}
buffer := make([]byte, 1024*1024) // 1MB buffer
for {
n, err := dbFile.Read(buffer)
if n > 0 {
if _, err := dbWriter.Write(buffer[:n]); err != nil {
return nil, fmt.Errorf("写入数据库内容失败: %v", err)
}
}
if err != nil {
break
}
}
// 添加元数据文件
metaData := map[string]interface{}{
"backup_time": time.Now().Format("2006-01-02 15:04:05"),
"version": "1.0",
}
metaWriter, err := zipWriter.Create("metadata.json")
if err != nil {
return nil, fmt.Errorf("创建元数据文件失败: %v", err)
}
metaJSON, err := json.Marshal(metaData)
if err != nil {
return nil, fmt.Errorf("序列化元数据失败: %v", err)
}
if _, err := metaWriter.Write(metaJSON); err != nil {
return nil, fmt.Errorf("写入元数据失败: %v", err)
}
// 获取备份文件大小
fileInfo, err := zipFile.Stat()
if err != nil {
return nil, fmt.Errorf("获取备份文件信息失败: %v", err)
}
return &BackupResult{
BackupPath: backupPath,
FileName: backupFileName,
FileSize: fileInfo.Size(),
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}, nil
}
// Restore 恢复数据
func (s *BackupService) Restore(backupPath string) error {
// 检查备份文件是否存在
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
return fmt.Errorf("备份文件不存在: %s", backupPath)
}
// 打开 ZIP 文件
zipReader, err := zip.OpenReader(backupPath)
if err != nil {
return fmt.Errorf("打开备份文件失败: %v", err)
}
defer zipReader.Close()
// 获取 SQLite 数据库路径
appDataDir, err := os.UserConfigDir()
if err != nil {
return fmt.Errorf("获取用户配置目录失败: %v", err)
}
dataDir := filepath.Join(appDataDir, "ssq-desk", "data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("创建数据目录失败: %v", err)
}
dbPath := filepath.Join(dataDir, "ssq.db")
// 备份当前数据库(如果存在)
if _, err := os.Stat(dbPath); err == nil {
backupName := fmt.Sprintf("ssq.db.bak.%s", time.Now().Format("20060102-150405"))
backupPath := filepath.Join(dataDir, backupName)
if err := copyFile(dbPath, backupPath); err != nil {
return fmt.Errorf("备份当前数据库失败: %v", err)
}
}
// 查找数据库文件
var dbFile *zip.File
for _, file := range zipReader.File {
if file.Name == "ssq.db" {
dbFile = file
break
}
}
if dbFile == nil {
return fmt.Errorf("备份文件中未找到数据库文件")
}
// 解压数据库文件
rc, err := dbFile.Open()
if err != nil {
return fmt.Errorf("打开数据库文件失败: %v", err)
}
defer rc.Close()
// 创建新的数据库文件
newDBFile, err := os.Create(dbPath)
if err != nil {
return fmt.Errorf("创建数据库文件失败: %v", err)
}
defer newDBFile.Close()
// 复制数据
buffer := make([]byte, 1024*1024) // 1MB buffer
for {
n, err := rc.Read(buffer)
if n > 0 {
if _, err := newDBFile.Write(buffer[:n]); err != nil {
return fmt.Errorf("写入数据库文件失败: %v", err)
}
}
if err != nil {
break
}
}
// 重新初始化数据库连接
_, err = database.InitSQLite()
if err != nil {
return fmt.Errorf("重新初始化数据库失败: %v", err)
}
return nil
}
// ListBackups 列出所有备份文件
func (s *BackupService) ListBackups() ([]BackupResult, error) {
appDataDir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("获取用户配置目录失败: %v", err)
}
backupDir := filepath.Join(appDataDir, "ssq-desk", "backups")
// 检查备份目录是否存在
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
return []BackupResult{}, nil
}
files, err := os.ReadDir(backupDir)
if err != nil {
return nil, fmt.Errorf("读取备份目录失败: %v", err)
}
var backups []BackupResult
for _, file := range files {
if filepath.Ext(file.Name()) == ".zip" {
filePath := filepath.Join(backupDir, file.Name())
fileInfo, err := file.Info()
if err != nil {
continue
}
backups = append(backups, BackupResult{
BackupPath: filePath,
FileName: file.Name(),
FileSize: fileInfo.Size(),
CreatedAt: fileInfo.ModTime().Format("2006-01-02 15:04:05"),
})
}
}
return backups, nil
}
// copyFile 复制文件
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = destFile.ReadFrom(sourceFile)
return err
}

View File

@@ -0,0 +1,281 @@
package service
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"ssq-desk/internal/storage/repository"
"time"
)
// PackageService 离线数据包服务
type PackageService struct {
sqliteRepo repository.SsqRepository
}
// NewPackageService 创建数据包服务
func NewPackageService() (*PackageService, error) {
repo, err := repository.NewSQLiteSsqRepository()
if err != nil {
return nil, err
}
return &PackageService{
sqliteRepo: repo,
}, nil
}
// PackageInfo 数据包信息
type PackageInfo struct {
Version string `json:"version"` // 数据包版本
TotalCount int `json:"total_count"` // 数据总数
LatestIssue string `json:"latest_issue"` // 最新期号
PackageSize int64 `json:"package_size"` // 包大小
DownloadURL string `json:"download_url"` // 下载地址
ReleaseDate string `json:"release_date"` // 发布日期
CheckSum string `json:"checksum"` // 校验和
}
// PackageDownloadResult 数据包下载结果
type PackageDownloadResult struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
Duration string `json:"duration"` // 下载耗时
}
// ImportResult 导入结果
type ImportResult struct {
ImportedCount int `json:"imported_count"` // 导入数量
UpdatedCount int `json:"updated_count"` // 更新数量
ErrorCount int `json:"error_count"` // 错误数量
Duration string `json:"duration"` // 导入耗时
}
// DownloadPackage 下载数据包
func (s *PackageService) DownloadPackage(downloadURL string, progressCallback func(int64, int64)) (*PackageDownloadResult, error) {
startTime := time.Now()
// 创建下载目录
appDataDir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("获取用户配置目录失败: %v", err)
}
downloadDir := filepath.Join(appDataDir, "ssq-desk", "packages")
if err := os.MkdirAll(downloadDir, 0755); err != nil {
return nil, fmt.Errorf("创建下载目录失败: %v", err)
}
// 生成文件名
filename := filepath.Base(downloadURL)
if filename == "" || filename == "." {
filename = fmt.Sprintf("ssq-data-%s.zip", time.Now().Format("20060102-150405"))
}
filePath := filepath.Join(downloadDir, filename)
// 创建文件
out, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("创建文件失败: %v", err)
}
defer out.Close()
// 发起 HTTP 请求
resp, err := http.Get(downloadURL)
if err != nil {
return nil, fmt.Errorf("下载失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
}
// 获取文件大小
totalSize := resp.ContentLength
// 复制数据并显示进度
var written int64
buffer := make([]byte, 32*1024) // 32KB buffer
for {
nr, er := resp.Body.Read(buffer)
if nr > 0 {
nw, ew := out.Write(buffer[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
ew = fmt.Errorf("无效写入结果")
}
}
written += int64(nw)
if ew != nil {
err = ew
break
}
if nr != nw {
err = io.ErrShortWrite
break
}
// 调用进度回调
if progressCallback != nil {
progressCallback(written, totalSize)
}
}
if er != nil {
if er != io.EOF {
err = er
}
break
}
}
if err != nil {
os.Remove(filePath)
return nil, fmt.Errorf("写入文件失败: %v", err)
}
// 获取文件信息
fileInfo, err := out.Stat()
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %v", err)
}
duration := time.Since(startTime).String()
return &PackageDownloadResult{
FilePath: filePath,
FileSize: fileInfo.Size(),
Duration: duration,
}, nil
}
// ImportPackage 导入数据包
func (s *PackageService) ImportPackage(packagePath string) (*ImportResult, error) {
startTime := time.Now()
result := &ImportResult{}
// 检查文件是否存在
if _, err := os.Stat(packagePath); os.IsNotExist(err) {
return nil, fmt.Errorf("数据包文件不存在: %s", packagePath)
}
// 打开 ZIP 文件
zipReader, err := zip.OpenReader(packagePath)
if err != nil {
return nil, fmt.Errorf("打开数据包失败: %v", err)
}
defer zipReader.Close()
// 查找数据文件JSON 格式)
var dataFile *zip.File
for _, file := range zipReader.File {
if filepath.Ext(file.Name) == ".json" {
dataFile = file
break
}
}
if dataFile == nil {
return nil, fmt.Errorf("数据包中未找到 JSON 数据文件")
}
// 读取数据文件
rc, err := dataFile.Open()
if err != nil {
return nil, fmt.Errorf("打开数据文件失败: %v", err)
}
defer rc.Close()
// 解析 JSON
var histories []map[string]interface{}
decoder := json.NewDecoder(rc)
if err := decoder.Decode(&histories); err != nil {
return nil, fmt.Errorf("解析数据文件失败: %v", err)
}
// 导入数据
// TODO: 实际导入逻辑需要根据数据包格式实现
// 这里只是框架代码,需要将 JSON 数据转换为 SsqHistory 并插入数据库
for range histories {
// 转换为 SsqHistory 结构(这里需要根据实际数据格式转换)
// 简化处理:直接创建记录
// 实际应用中需要根据数据包格式解析
result.ImportedCount++
}
// TODO: 实际导入逻辑需要根据数据包格式实现
// 这里只是框架代码
result.Duration = time.Since(startTime).String()
return result, nil
}
// CheckPackageUpdate 检查数据包更新
func (s *PackageService) CheckPackageUpdate(remoteURL string) (*PackageInfo, error) {
// 发起 HTTP 请求获取数据包信息
resp, err := http.Get(remoteURL)
if err != nil {
return nil, fmt.Errorf("获取数据包信息失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取数据包信息失败: HTTP %d", resp.StatusCode)
}
// 解析 JSON
var info PackageInfo
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(&info); err != nil {
return nil, fmt.Errorf("解析数据包信息失败: %v", err)
}
// 获取本地最新期号
localLatestIssue, err := s.sqliteRepo.GetLatestIssue()
if err != nil {
return nil, fmt.Errorf("获取本地最新期号失败: %v", err)
}
// 比较是否需要更新
// 如果远程最新期号大于本地,则需要更新
if info.LatestIssue > localLatestIssue {
return &info, nil
}
return nil, nil // 不需要更新
}
// ListLocalPackages 列出本地数据包
func (s *PackageService) ListLocalPackages() ([]string, error) {
appDataDir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("获取用户配置目录失败: %v", err)
}
packageDir := filepath.Join(appDataDir, "ssq-desk", "packages")
// 检查目录是否存在
if _, err := os.Stat(packageDir); os.IsNotExist(err) {
return []string{}, nil
}
files, err := os.ReadDir(packageDir)
if err != nil {
return nil, fmt.Errorf("读取数据包目录失败: %v", err)
}
var packages []string
for _, file := range files {
if filepath.Ext(file.Name()) == ".zip" {
packages = append(packages, filepath.Join(packageDir, file.Name()))
}
}
return packages, nil
}

View File

@@ -0,0 +1,162 @@
package service
import (
"fmt"
"ssq-desk/internal/storage/models"
"ssq-desk/internal/storage/repository"
)
// QueryService 查询服务
type QueryService struct {
repo repository.SsqRepository
}
// NewQueryService 创建查询服务
func NewQueryService(repo repository.SsqRepository) *QueryService {
return &QueryService{repo: repo}
}
// QueryRequest 查询请求
type QueryRequest struct {
RedBalls []int `json:"red_balls"` // 红球列表最多6个
BlueBall int `json:"blue_ball"` // 蓝球0表示不限制
BlueBallRange []int `json:"blue_ball_range"` // 蓝球筛选范围
}
// QueryResult 查询结果
type QueryResult struct {
Total int64 `json:"total"` // 总记录数
Summary []MatchSummary `json:"summary"` // 分类统计
Details []models.SsqHistory `json:"details"` // 详细记录
}
// MatchSummary 匹配统计
type MatchSummary struct {
Type string `json:"type"` // 匹配类型:如 "6红1蓝"
Count int `json:"count"` // 匹配数量
Histories []models.SsqHistory `json:"histories"` // 匹配的记录
}
// Query 执行查询
func (s *QueryService) Query(req QueryRequest) (*QueryResult, error) {
// 验证输入
if err := s.validateRequest(req); err != nil {
return nil, err
}
// 查询数据
histories, err := s.repo.FindByRedAndBlue(req.RedBalls, req.BlueBall, req.BlueBallRange)
if err != nil {
return nil, fmt.Errorf("查询失败: %v", err)
}
// 处理结果:分类统计
summary := s.calculateSummary(histories, req.RedBalls, req.BlueBall)
return &QueryResult{
Total: int64(len(histories)),
Summary: summary,
Details: histories,
}, nil
}
// validateRequest 验证请求参数
func (s *QueryService) validateRequest(req QueryRequest) error {
// 验证红球
if len(req.RedBalls) > 6 {
return fmt.Errorf("红球数量不能超过6个")
}
for _, ball := range req.RedBalls {
if ball < 1 || ball > 33 {
return fmt.Errorf("红球号码必须在1-33之间: %d", ball)
}
}
// 验证蓝球
if req.BlueBall > 0 {
if req.BlueBall < 1 || req.BlueBall > 16 {
return fmt.Errorf("蓝球号码必须在1-16之间: %d", req.BlueBall)
}
}
// 验证蓝球范围
for _, ball := range req.BlueBallRange {
if ball < 1 || ball > 16 {
return fmt.Errorf("蓝球筛选范围必须在1-16之间: %d", ball)
}
}
return nil
}
// calculateSummary 计算分类统计
func (s *QueryService) calculateSummary(histories []models.SsqHistory, redBalls []int, blueBall int) []MatchSummary {
if len(redBalls) == 0 {
return []MatchSummary{}
}
// 创建红球集合,用于快速查找
redBallSet := make(map[int]bool)
for _, ball := range redBalls {
redBallSet[ball] = true
}
// 初始化统计
typeCounts := make(map[string][]models.SsqHistory)
// 遍历每条记录,计算匹配度
for _, history := range histories {
// 统计匹配的红球数量
matchedRedCount := 0
if redBallSet[history.RedBall1] {
matchedRedCount++
}
if redBallSet[history.RedBall2] {
matchedRedCount++
}
if redBallSet[history.RedBall3] {
matchedRedCount++
}
if redBallSet[history.RedBall4] {
matchedRedCount++
}
if redBallSet[history.RedBall5] {
matchedRedCount++
}
if redBallSet[history.RedBall6] {
matchedRedCount++
}
// 判断蓝球是否匹配
blueMatched := false
if blueBall > 0 {
blueMatched = history.BlueBall == blueBall
} else {
blueMatched = true // 未指定蓝球时,视为匹配
}
// 生成类型键
typeKey := fmt.Sprintf("%d红", matchedRedCount)
if blueMatched {
typeKey += "1蓝"
}
typeCounts[typeKey] = append(typeCounts[typeKey], history)
}
// 转换为结果格式,按匹配度排序
summary := make([]MatchSummary, 0)
types := []string{"6红1蓝", "6红", "5红1蓝", "5红", "4红1蓝", "4红", "3红1蓝", "3红", "2红1蓝", "2红", "1红1蓝", "1红", "0红1蓝", "0红"}
for _, t := range types {
if histories, ok := typeCounts[t]; ok {
summary = append(summary, MatchSummary{
Type: t,
Count: len(histories),
Histories: histories,
})
}
}
return summary
}

View File

@@ -0,0 +1,148 @@
package service
import (
"fmt"
"ssq-desk/internal/storage/models"
"ssq-desk/internal/storage/repository"
)
// SyncService 数据同步服务
type SyncService struct {
mysqlRepo repository.SsqRepository
sqliteRepo repository.SsqRepository
}
// NewSyncService 创建同步服务
func NewSyncService(mysqlRepo, sqliteRepo repository.SsqRepository) *SyncService {
return &SyncService{
mysqlRepo: mysqlRepo,
sqliteRepo: sqliteRepo,
}
}
// SyncResult 同步结果
type SyncResult struct {
TotalCount int `json:"total_count"` // 远程数据总数
SyncedCount int `json:"synced_count"` // 已同步数量
NewCount int `json:"new_count"` // 新增数量
UpdatedCount int `json:"updated_count"` // 更新数量
ErrorCount int `json:"error_count"` // 错误数量
LatestIssue string `json:"latest_issue"` // 最新期号
}
// Sync 执行数据同步(增量同步)
func (s *SyncService) Sync() (*SyncResult, error) {
result := &SyncResult{}
// 获取本地最新期号
localLatestIssue, err := s.sqliteRepo.GetLatestIssue()
if err != nil {
return nil, fmt.Errorf("获取本地最新期号失败: %v", err)
}
// 获取远程所有数据
remoteHistories, err := s.mysqlRepo.FindAll()
if err != nil {
return nil, fmt.Errorf("获取远程数据失败: %v", err)
}
result.TotalCount = len(remoteHistories)
if len(remoteHistories) == 0 {
return result, nil
}
// 确定最新期号
if len(remoteHistories) > 0 {
result.LatestIssue = remoteHistories[0].IssueNumber
}
// 增量同步:只同步本地没有的数据
if localLatestIssue == "" {
// 首次同步,全量同步
for _, history := range remoteHistories {
if err := s.sqliteRepo.Create(&history); err != nil {
result.ErrorCount++
continue
}
result.NewCount++
result.SyncedCount++
}
} else {
// 增量同步:同步期号大于本地最新期号的数据
for _, history := range remoteHistories {
// 如果期号小于等于本地最新期号,跳过
if history.IssueNumber <= localLatestIssue {
continue
}
// 检查本地是否已存在(基于期号)
localHistory, err := s.sqliteRepo.FindByIssue(history.IssueNumber)
if err == nil && localHistory != nil {
// 已存在,检查是否需要更新
if s.needUpdate(localHistory, &history) {
// 更新逻辑(目前使用创建,如需更新可扩展)
result.UpdatedCount++
}
continue
}
// 新数据,插入
if err := s.sqliteRepo.Create(&history); err != nil {
result.ErrorCount++
continue
}
result.NewCount++
result.SyncedCount++
}
}
return result, nil
}
// needUpdate 判断是否需要更新
func (s *SyncService) needUpdate(local, remote *models.SsqHistory) bool {
// 简单比较:如果任何字段不同,则认为需要更新
return local.RedBall1 != remote.RedBall1 ||
local.RedBall2 != remote.RedBall2 ||
local.RedBall3 != remote.RedBall3 ||
local.RedBall4 != remote.RedBall4 ||
local.RedBall5 != remote.RedBall5 ||
local.RedBall6 != remote.RedBall6 ||
local.BlueBall != remote.BlueBall
}
// GetSyncStatus 获取同步状态
func (s *SyncService) GetSyncStatus() (map[string]interface{}, error) {
// 获取本地统计
localCount, err := s.sqliteRepo.Count()
if err != nil {
return nil, err
}
localLatestIssue, err := s.sqliteRepo.GetLatestIssue()
if err != nil {
return nil, err
}
// 获取远程统计
remoteCount, err := s.mysqlRepo.Count()
if err != nil {
// 远程连接失败不影响本地状态
remoteCount = 0
}
remoteLatestIssue := ""
remoteHistories, err := s.mysqlRepo.FindAll()
if err == nil && len(remoteHistories) > 0 {
remoteLatestIssue = remoteHistories[0].IssueNumber
}
return map[string]interface{}{
"local_count": localCount,
"local_latest_issue": localLatestIssue,
"remote_count": remoteCount,
"remote_latest_issue": remoteLatestIssue,
"need_sync": remoteLatestIssue > localLatestIssue,
}, nil
}

View File

@@ -0,0 +1,138 @@
package service
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
// UpdateConfig 更新配置
type UpdateConfig struct {
CurrentVersion string `json:"current_version"`
LastCheckTime time.Time `json:"last_check_time"`
AutoCheckEnabled bool `json:"auto_check_enabled"`
CheckIntervalMinutes int `json:"check_interval_minutes"` // 检查间隔(分钟)
CheckURL string `json:"check_url,omitempty"` // 版本检查接口 URL
}
// GetUpdateConfigPath 获取更新配置文件路径
func GetUpdateConfigPath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败: %v", err)
}
configDir := filepath.Join(homeDir, ".ssq-desk")
if err := os.MkdirAll(configDir, 0755); err != nil {
return "", fmt.Errorf("创建配置目录失败: %v", err)
}
return filepath.Join(configDir, "update_config.json"), nil
}
// LoadUpdateConfig 加载更新配置
func LoadUpdateConfig() (*UpdateConfig, error) {
configPath, err := GetUpdateConfigPath()
if err != nil {
return nil, err
}
// 如果文件不存在,返回默认配置
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return &UpdateConfig{
CurrentVersion: GetCurrentVersion(),
LastCheckTime: time.Time{},
AutoCheckEnabled: true,
CheckIntervalMinutes: 1, // 默认1分钟检查一次
CheckURL: "https://img.1216.top/ssq/last-version.json", // 默认版本检查地址
}, nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("读取配置文件失败: %v", err)
}
// 先解析为 map 以支持兼容旧配置
var configMap map[string]interface{}
if err := json.Unmarshal(data, &configMap); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %v", err)
}
var config UpdateConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %v", err)
}
// 兼容旧配置:如果存在 check_interval_days转换为分钟
if config.CheckIntervalMinutes == 0 {
if days, ok := configMap["check_interval_days"].(float64); ok && days > 0 {
config.CheckIntervalMinutes = int(days * 24 * 60) // 转换为分钟
} else {
config.CheckIntervalMinutes = 1 // 默认1分钟
}
}
// 获取最新版本号
latestVersion := GetCurrentVersion()
// 如果当前版本为空或与最新版本不一致,更新为最新版本
if config.CurrentVersion == "" || config.CurrentVersion != latestVersion {
if config.CurrentVersion != "" {
log.Printf("[配置] 配置中的版本号 (%s) 与最新版本号 (%s) 不一致,更新配置", config.CurrentVersion, latestVersion)
}
config.CurrentVersion = latestVersion
// 注意:这里不自动保存,避免频繁写入文件,由调用方决定是否保存
}
// 如果检查地址为空,使用默认地址
if config.CheckURL == "" {
config.CheckURL = "https://img.1216.top/ssq/last-version.json"
}
return &config, nil
}
// SaveUpdateConfig 保存更新配置
func SaveUpdateConfig(config *UpdateConfig) error {
configPath, err := GetUpdateConfigPath()
if err != nil {
return err
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("序列化配置失败: %v", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("写入配置文件失败: %v", err)
}
return nil
}
// ShouldCheckUpdate 判断是否应该检查更新
func (c *UpdateConfig) ShouldCheckUpdate() bool {
if !c.AutoCheckEnabled {
return false
}
// 如果从未检查过,应该检查
if c.LastCheckTime.IsZero() {
return true
}
// 检查是否超过间隔分钟数
minutesSinceLastCheck := time.Since(c.LastCheckTime).Minutes()
return minutesSinceLastCheck >= float64(c.CheckIntervalMinutes)
}
// UpdateLastCheckTime 更新最后检查时间
func (c *UpdateConfig) UpdateLastCheckTime() error {
c.LastCheckTime = time.Now()
return SaveUpdateConfig(c)
}

View File

@@ -0,0 +1,332 @@
package service
import (
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
// getRemoteFileSize 通过HEAD请求获取远程文件大小
func getRemoteFileSize(url string) (int64, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return 0, err
}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.ContentLength > 0 {
return resp.ContentLength, nil
}
return 0, fmt.Errorf("无法获取文件大小")
}
// normalizeProgress 标准化进度值到0-100之间
func normalizeProgress(progress float64) float64 {
if progress < 0 {
return 0
}
if progress > 100 {
return 100
}
return progress
}
// DownloadProgress 下载进度回调函数类型
type DownloadProgress func(progress float64, speed float64, downloaded int64, total int64)
// DownloadProgressInfo 下载进度信息
type DownloadProgressInfo struct {
Progress float64 `json:"progress"` // 进度百分比
Speed float64 `json:"speed"` // 下载速度(字节/秒)
Downloaded int64 `json:"downloaded"` // 已下载字节数
Total int64 `json:"total"` // 总字节数
Error string `json:"error,omitempty"` // 错误信息
Result *DownloadResult `json:"result,omitempty"` // 下载结果
}
// DownloadResult 下载结果
type DownloadResult struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
MD5Hash string `json:"md5_hash,omitempty"`
SHA256Hash string `json:"sha256_hash,omitempty"`
}
// DownloadUpdate 下载更新包
func DownloadUpdate(downloadURL string, progressCallback DownloadProgress) (*DownloadResult, error) {
// 获取下载目录
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("获取用户目录失败: %v", err)
}
downloadDir := filepath.Join(homeDir, ".ssq-desk", "downloads")
if err := os.MkdirAll(downloadDir, 0755); err != nil {
return nil, fmt.Errorf("创建下载目录失败: %v", err)
}
// 从 URL 提取文件名
filename := filepath.Base(downloadURL)
if filename == "" || filename == "." {
filename = fmt.Sprintf("update-%d.exe", time.Now().Unix())
}
filePath := filepath.Join(downloadDir, filename)
// 检查文件是否已存在
var downloadedSize int64 = 0
if fileInfo, err := os.Stat(filePath); err == nil {
downloadedSize = fileInfo.Size()
// 检查是否已完整下载
if remoteSize, err := getRemoteFileSize(downloadURL); err == nil && downloadedSize == remoteSize {
if progressCallback != nil {
progressCallback(100.0, 0, downloadedSize, remoteSize)
time.Sleep(50 * time.Millisecond)
}
md5Hash, sha256Hash, err := calculateFileHashes(filePath)
if err != nil {
return nil, fmt.Errorf("计算文件哈希失败: %v", err)
}
return &DownloadResult{
FilePath: filePath,
FileSize: downloadedSize,
MD5Hash: md5Hash,
SHA256Hash: sha256Hash,
}, nil
}
}
// 打开文件(支持断点续传)
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, fmt.Errorf("创建文件失败: %v", err)
}
defer file.Close()
// 创建 HTTP 请求
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
}
// 如果已下载部分,设置 Range 头(断点续传)
if downloadedSize > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", downloadedSize))
}
// 发送请求
client := &http.Client{Timeout: 30 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("下载请求失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
file.Close()
if fileInfo, err := os.Stat(filePath); err == nil {
if remoteSize, err := getRemoteFileSize(downloadURL); err == nil && fileInfo.Size() == remoteSize {
if progressCallback != nil {
progressCallback(100.0, 0, fileInfo.Size(), remoteSize)
}
md5Hash, sha256Hash, err := calculateFileHashes(filePath)
if err != nil {
return nil, fmt.Errorf("计算文件哈希失败: %v", err)
}
return &DownloadResult{
FilePath: filePath,
FileSize: fileInfo.Size(),
MD5Hash: md5Hash,
SHA256Hash: sha256Hash,
}, nil
}
}
return nil, fmt.Errorf("服务器返回 416 错误,且文件可能不完整")
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return nil, fmt.Errorf("服务器返回错误状态码: %d", resp.StatusCode)
}
// 获取文件总大小
contentLength := resp.ContentLength
gotTotalFromRange := false
// 优先从 Content-Range 头获取总大小
if rangeHeader := resp.Header.Get("Content-Range"); rangeHeader != "" {
var start, end, total int64
if n, _ := fmt.Sscanf(rangeHeader, "bytes %d-%d/%d", &start, &end, &total); n == 3 && total > 0 {
contentLength = total
gotTotalFromRange = true
}
}
// 如果未获取到,尝试通过 HEAD 请求获取
if contentLength <= 0 && !gotTotalFromRange {
if remoteSize, err := getRemoteFileSize(downloadURL); err == nil {
contentLength = remoteSize
}
}
// 断点续传时,如果未从 Content-Range 获取,需要加上已下载部分
if resp.StatusCode == http.StatusPartialContent && downloadedSize > 0 && contentLength > 0 && !gotTotalFromRange {
if contentLength < downloadedSize {
contentLength += downloadedSize
}
}
// 发送初始进度事件
if progressCallback != nil {
time.Sleep(50 * time.Millisecond)
if contentLength > 0 && downloadedSize > 0 {
progress := normalizeProgress(float64(downloadedSize) / float64(contentLength) * 100)
progressCallback(progress, 0, downloadedSize, contentLength)
} else {
progressCallback(0, 0, downloadedSize, -1)
}
time.Sleep(20 * time.Millisecond)
}
// 下载文件
buffer := make([]byte, 32*1024) // 32KB 缓冲区
var totalDownloaded int64 = downloadedSize
startTime := time.Now()
lastProgressTime := startTime
lastProgressSize := totalDownloaded
for {
n, err := resp.Body.Read(buffer)
if n > 0 {
written, writeErr := file.Write(buffer[:n])
if writeErr != nil {
return nil, fmt.Errorf("写入文件失败: %v", writeErr)
}
totalDownloaded += int64(written)
// 计算进度和速度
now := time.Now()
elapsed := now.Sub(lastProgressTime).Seconds()
// 每 0.3 秒更新一次进度
if elapsed >= 0.3 {
progress := float64(0)
if contentLength > 0 {
progress = normalizeProgress(float64(totalDownloaded) / float64(contentLength) * 100)
}
speed := float64(0)
if elapsed > 0 {
speed = float64(totalDownloaded-lastProgressSize) / elapsed
}
if progressCallback != nil {
progressCallback(progress, speed, totalDownloaded, contentLength)
}
lastProgressTime = now
lastProgressSize = totalDownloaded
}
}
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("读取数据失败: %v", err)
}
}
// 最后一次进度更新
if progressCallback != nil {
if contentLength > 0 {
progressCallback(100.0, 0, totalDownloaded, contentLength)
} else {
progressCallback(100.0, 0, totalDownloaded, totalDownloaded)
}
}
file.Close()
md5Hash, sha256Hash, err := calculateFileHashes(filePath)
if err != nil {
return nil, fmt.Errorf("计算文件哈希失败: %v", err)
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %v", err)
}
return &DownloadResult{
FilePath: filePath,
FileSize: fileInfo.Size(),
MD5Hash: md5Hash,
SHA256Hash: sha256Hash,
}, nil
}
// calculateFileHashes 计算文件的 MD5 和 SHA256 哈希值
func calculateFileHashes(filePath string) (string, string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", "", err
}
defer file.Close()
md5Hash := md5.New()
sha256Hash := sha256.New()
// 使用 MultiWriter 同时计算两个哈希
writer := io.MultiWriter(md5Hash, sha256Hash)
if _, err := io.Copy(writer, file); err != nil {
return "", "", err
}
md5Sum := hex.EncodeToString(md5Hash.Sum(nil))
sha256Sum := hex.EncodeToString(sha256Hash.Sum(nil))
return md5Sum, sha256Sum, nil
}
// VerifyFileHash 验证文件哈希值
func VerifyFileHash(filePath string, expectedHash string, hashType string) (bool, error) {
file, err := os.Open(filePath)
if err != nil {
return false, err
}
defer file.Close()
var hash []byte
var calculatedHash string
switch hashType {
case "md5":
md5Hash := md5.New()
if _, err := io.Copy(md5Hash, file); err != nil {
return false, err
}
hash = md5Hash.Sum(nil)
calculatedHash = hex.EncodeToString(hash)
case "sha256":
sha256Hash := sha256.New()
if _, err := io.Copy(sha256Hash, file); err != nil {
return false, err
}
hash = sha256Hash.Sum(nil)
calculatedHash = hex.EncodeToString(hash)
default:
return false, fmt.Errorf("不支持的哈希类型: %s", hashType)
}
return calculatedHash == expectedHash, nil
}

View File

@@ -0,0 +1,328 @@
package service
import (
"archive/zip"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
// InstallResult 安装结果
type InstallResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// InstallUpdate 安装更新包
func InstallUpdate(installerPath string, autoRestart bool) (*InstallResult, error) {
return InstallUpdateWithHash(installerPath, autoRestart, "", "")
}
// InstallUpdateWithHash 安装更新包(带哈希验证)
func InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (*InstallResult, error) {
if _, err := os.Stat(installerPath); os.IsNotExist(err) {
return nil, fmt.Errorf("安装文件不存在: %s", installerPath)
}
// 哈希验证
if expectedHash != "" && hashType != "" {
valid, err := VerifyFileHash(installerPath, expectedHash, hashType)
if err != nil {
return &InstallResult{Success: false, Message: "文件验证失败: " + err.Error()}, nil
}
if !valid {
return &InstallResult{Success: false, Message: "文件哈希值不匹配,文件可能已损坏或被篡改"}, nil
}
}
// 备份
backupPath, err := BackupApplication()
if err != nil {
return &InstallResult{Success: false, Message: fmt.Sprintf("备份失败: %v", err)}, nil
}
// 安装
ext := filepath.Ext(installerPath)
switch ext {
case ".exe":
if runtime.GOOS != "windows" {
return &InstallResult{Success: false, Message: "当前系统不是 Windows无法安装 .exe 文件"}, nil
}
err = installExe(installerPath)
case ".zip":
err = installZip(installerPath)
default:
return nil, fmt.Errorf("不支持的安装包格式: %s", ext)
}
// 处理安装结果
if err != nil {
// 安装失败,尝试回滚
if backupPath != "" {
_ = rollbackFromBackup(backupPath)
}
return &InstallResult{Success: false, Message: fmt.Sprintf("安装失败: %v", err)}, nil
}
// 自动重启
if autoRestart {
go func() {
time.Sleep(2 * time.Second)
restartApplication()
}()
}
return &InstallResult{Success: true, Message: "安装成功"}, nil
}
// getExecutablePath 获取当前可执行文件路径
func getExecutablePath() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("获取可执行文件路径失败: %v", err)
}
return execPath, nil
}
// installExe 安装 exe 文件
func installExe(exePath string) error {
execPath, err := getExecutablePath()
if err != nil {
return err
}
return replaceExecutableFile(exePath, execPath)
}
// installZip 安装 ZIP 压缩包
func installZip(zipPath string) error {
zipReader, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("打开 ZIP 文件失败: %v", err)
}
defer zipReader.Close()
execPath, err := getExecutablePath()
if err != nil {
return err
}
execDir := filepath.Dir(execPath)
execName := filepath.Base(execPath)
// 解压到临时目录
tempDir := filepath.Join(execDir, ".update-temp")
if err := os.MkdirAll(tempDir, 0755); err != nil {
return fmt.Errorf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tempDir)
// 解压文件
for _, file := range zipReader.File {
filePath := filepath.Join(tempDir, file.Name)
if file.FileInfo().IsDir() {
os.MkdirAll(filePath, file.FileInfo().Mode())
continue
}
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return fmt.Errorf("创建目录失败: %v", err)
}
rc, err := file.Open()
if err != nil {
return fmt.Errorf("打开 ZIP 文件项失败: %v", err)
}
targetFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode())
if err != nil {
rc.Close()
return fmt.Errorf("创建目标文件失败: %v", err)
}
if _, err := io.Copy(targetFile, rc); err != nil {
targetFile.Close()
rc.Close()
return fmt.Errorf("复制文件失败: %v", err)
}
targetFile.Close()
rc.Close()
}
// 查找新的可执行文件
newExecPath := filepath.Join(tempDir, execName)
if _, err := os.Stat(newExecPath); os.IsNotExist(err) {
files, _ := os.ReadDir(tempDir)
for _, f := range files {
if !f.IsDir() {
newExecPath = filepath.Join(tempDir, f.Name())
break
}
}
}
// 替换文件(使用与 installExe 相同的逻辑)
return replaceExecutableFile(newExecPath, execPath)
}
// replaceExecutableFile 替换可执行文件Windows 和 Unix 通用逻辑)
func replaceExecutableFile(newFilePath, execPath string) error {
if runtime.GOOS == "windows" {
return replaceExecutableFileWindows(newFilePath, execPath)
}
// Unix-like: 直接替换
if err := copyFile(newFilePath, execPath); err != nil {
return fmt.Errorf("复制文件失败: %v", err)
}
return os.Chmod(execPath, 0755)
}
// replaceExecutableFileWindows Windows 平台替换可执行文件
func replaceExecutableFileWindows(newFilePath, execPath string) error {
oldExecPath := execPath + ".old"
newExecPathTemp := execPath + ".new"
// 清理旧文件
os.Remove(oldExecPath)
os.Remove(newExecPathTemp)
// 复制新文件到临时位置
if err := copyFile(newFilePath, newExecPathTemp); err != nil {
return fmt.Errorf("复制新文件失败: %v", err)
}
// 尝试重命名当前文件(如果失败,说明文件正在使用)
if err := os.Rename(execPath, oldExecPath); err != nil {
return nil
}
// 替换文件
if err := os.Rename(newExecPathTemp, execPath); err != nil {
os.Rename(oldExecPath, execPath) // 恢复
return fmt.Errorf("替换文件失败: %v", err)
}
// 延迟删除旧文件
go func() {
time.Sleep(10 * time.Second)
os.Remove(oldExecPath)
}()
return nil
}
// restartApplication 重启应用
func restartApplication() {
execPath, err := getExecutablePath()
if err != nil {
return
}
currentPID := os.Getpid()
if runtime.GOOS != "windows" {
return
}
replacePendingFile(execPath)
// 创建并执行重启脚本
tempDir := os.TempDir()
batFile := filepath.Join(tempDir, fmt.Sprintf("restart_%d.bat", currentPID))
execDir := filepath.Dir(execPath)
batContent := fmt.Sprintf(`@echo off
cd /d "%s"
start "" "%s"
timeout /t 3 /nobreak >nul
taskkill /PID %d /F >nul 2>&1
del "%%~f0"
`, execDir, execPath, currentPID)
if err := os.WriteFile(batFile, []byte(batContent), 0644); err != nil {
fallbackRestart(execPath)
return
}
cmd := exec.Command("cmd", "/C", batFile)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
fallbackRestart(execPath)
return
}
os.Exit(0)
}
// fallbackRestart 降级重启方案
func fallbackRestart(execPath string) {
exec.Command("cmd", "/C", "start", "", execPath).Start()
time.Sleep(2 * time.Second)
os.Exit(0)
}
// replacePendingFile 替换待替换的文件(.new -> 可执行文件)
func replacePendingFile(execPath string) error {
newExecPathTemp := execPath + ".new"
if _, err := os.Stat(newExecPathTemp); os.IsNotExist(err) {
return nil // 没有待替换文件
}
oldExecPath := execPath + ".old"
os.Remove(oldExecPath)
if err := os.Rename(newExecPathTemp, execPath); err != nil {
return fmt.Errorf("文件替换失败: %v", err)
}
return nil
}
// rollbackFromBackup 从备份恢复
func rollbackFromBackup(backupPath string) error {
execPath, err := getExecutablePath()
if err != nil {
return err
}
return copyFile(backupPath, execPath)
}
// BackupApplication 备份当前应用
func BackupApplication() (string, error) {
execPath, err := getExecutablePath()
if err != nil {
return "", err
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败: %v", err)
}
backupDir := filepath.Join(homeDir, ".ssq-desk", "backups")
if err := os.MkdirAll(backupDir, 0755); err != nil {
return "", fmt.Errorf("创建备份目录失败: %v", err)
}
timestamp := time.Now().Format("20060102-150405")
backupFileName := fmt.Sprintf("ssq-desk-backup-%s%s", timestamp, filepath.Ext(execPath))
backupPath := filepath.Join(backupDir, backupFileName)
sourceFile, err := os.Open(execPath)
if err != nil {
return "", fmt.Errorf("打开源文件失败: %v", err)
}
defer sourceFile.Close()
backupFile, err := os.Create(backupPath)
if err != nil {
return "", fmt.Errorf("创建备份文件失败: %v", err)
}
defer backupFile.Close()
if _, err := backupFile.ReadFrom(sourceFile); err != nil {
return "", fmt.Errorf("复制文件失败: %v", err)
}
return backupPath, nil
}

View File

@@ -0,0 +1,168 @@
package service
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
)
// UpdateService 更新服务
type UpdateService struct {
checkURL string // 版本检查接口 URL
}
// NewUpdateService 创建更新服务
func NewUpdateService(checkURL string) *UpdateService {
return &UpdateService{
checkURL: checkURL,
}
}
// RemoteVersionInfo 远程版本信息
type RemoteVersionInfo struct {
Version string `json:"version"`
DownloadURL string `json:"download_url"`
Changelog string `json:"changelog"`
ForceUpdate bool `json:"force_update"`
ReleaseDate string `json:"release_date"`
}
// CheckUpdate 检查更新
func (s *UpdateService) CheckUpdate() (*UpdateCheckResult, error) {
log.Printf("[更新检查] 开始检查更新,检查地址: %s", s.checkURL)
// 加载配置
config, err := LoadUpdateConfig()
if err != nil {
log.Printf("[更新检查] 加载配置失败: %v", err)
return nil, fmt.Errorf("加载配置失败: %v", err)
}
// 获取当前版本(优先使用服务获取的最新版本号,而不是配置中可能过期的版本号)
currentVersionStr := GetCurrentVersion()
if currentVersionStr == "" {
// 如果服务获取失败,回退到配置中的版本号
currentVersionStr = config.CurrentVersion
log.Printf("[更新检查] 使用配置中的版本号: %s", currentVersionStr)
} else {
log.Printf("[更新检查] 使用服务获取的版本号: %s", currentVersionStr)
// 如果配置中的版本号不一致,更新配置
if config.CurrentVersion != currentVersionStr {
log.Printf("[更新检查] 配置中的版本号 (%s) 与当前版本号 (%s) 不一致,更新配置", config.CurrentVersion, currentVersionStr)
config.CurrentVersion = currentVersionStr
if err := SaveUpdateConfig(config); err != nil {
log.Printf("[更新检查] 更新配置失败: %v", err)
}
}
}
currentVersion, err := ParseVersion(currentVersionStr)
if err != nil {
log.Printf("[更新检查] 解析当前版本失败: %v", err)
return nil, fmt.Errorf("解析当前版本失败: %v", err)
}
// 请求远程版本信息
remoteInfo, err := s.fetchRemoteVersionInfo()
if err != nil {
log.Printf("[更新检查] 获取远程版本信息失败: %v", err)
return nil, fmt.Errorf("获取远程版本信息失败: %v", err)
}
log.Printf("[更新检查] 远程版本信息: 版本=%s, 下载地址=%s, 强制更新=%v",
remoteInfo.Version, remoteInfo.DownloadURL, remoteInfo.ForceUpdate)
// 解析远程版本号
remoteVersion, err := ParseVersion(remoteInfo.Version)
if err != nil {
log.Printf("[更新检查] 解析远程版本号失败: %v", err)
return nil, fmt.Errorf("解析远程版本号失败: %v", err)
}
// 比较版本
hasUpdate := remoteVersion.IsNewerThan(currentVersion)
log.Printf("[更新检查] 版本比较: 当前=%s, 远程=%s, 有更新=%v",
currentVersion.String(), remoteVersion.String(), hasUpdate)
// 更新最后检查时间
config.UpdateLastCheckTime()
result := &UpdateCheckResult{
HasUpdate: hasUpdate,
CurrentVersion: currentVersionStr,
LatestVersion: remoteInfo.Version,
DownloadURL: remoteInfo.DownloadURL,
Changelog: remoteInfo.Changelog,
ForceUpdate: remoteInfo.ForceUpdate,
ReleaseDate: remoteInfo.ReleaseDate,
}
log.Printf("[更新检查] 检查完成: 有更新=%v", hasUpdate)
return result, nil
}
// fetchRemoteVersionInfo 获取远程版本信息
func (s *UpdateService) fetchRemoteVersionInfo() (*RemoteVersionInfo, error) {
if s.checkURL == "" {
log.Printf("[远程版本] 版本检查 URL 未配置")
return nil, fmt.Errorf("版本检查 URL 未配置,请先设置检查地址")
}
log.Printf("[远程版本] 请求远程版本信息: %s", s.checkURL)
// 创建 HTTP 客户端,设置超时
client := &http.Client{
Timeout: 10 * time.Second,
}
// 发送请求
resp, err := client.Get(s.checkURL)
if err != nil {
log.Printf("[远程版本] 网络请求失败: %v", err)
return nil, fmt.Errorf("网络请求失败: %v", err)
}
defer resp.Body.Close()
log.Printf("[远程版本] HTTP 响应状态码: %d", resp.StatusCode)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("服务器返回错误状态码: %d", resp.StatusCode)
}
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("[远程版本] 读取响应失败: %v", err)
return nil, fmt.Errorf("读取响应失败: %v", err)
}
log.Printf("[远程版本] 响应内容长度: %d 字节", len(body))
// 解析 JSON
var remoteInfo RemoteVersionInfo
if err := json.Unmarshal(body, &remoteInfo); err != nil {
log.Printf("[远程版本] 解析 JSON 失败: %v, 响应内容: %s", err, string(body))
return nil, fmt.Errorf("解析响应失败: %v", err)
}
if remoteInfo.Version == "" {
log.Printf("[远程版本] 远程版本信息不完整,响应内容: %s", string(body))
return nil, fmt.Errorf("远程版本信息不完整")
}
log.Printf("[远程版本] 成功获取远程版本信息: %+v", remoteInfo)
return &remoteInfo, nil
}
// UpdateCheckResult 更新检查结果
type UpdateCheckResult struct {
HasUpdate bool `json:"has_update"`
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
DownloadURL string `json:"download_url"`
Changelog string `json:"changelog"`
ForceUpdate bool `json:"force_update"`
ReleaseDate string `json:"release_date"`
}

159
internal/service/version.go Normal file
View File

@@ -0,0 +1,159 @@
package service
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
)
// ==================== 常量定义 ====================
// AppVersion 应用版本号(发布时直接修改此处)
const AppVersion = "0.1.1"
// ==================== 类型定义 ====================
// Version 版本号结构
type Version struct {
Major int
Minor int
Patch int
}
// WailsConfig Wails 配置文件结构
type WailsConfig struct {
Version string `json:"version"`
}
// ==================== 版本号解析和比较 ====================
// ParseVersion 解析版本号字符串(支持 v1.0.0 或 1.0.0 格式)
func ParseVersion(versionStr string) (*Version, error) {
versionStr = strings.TrimPrefix(versionStr, "v")
parts := strings.Split(versionStr, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("版本号格式错误,应为 x.y.z 格式")
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("主版本号解析失败: %v", err)
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("次版本号解析失败: %v", err)
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return nil, fmt.Errorf("修订号解析失败: %v", err)
}
return &Version{Major: major, Minor: minor, Patch: patch}, nil
}
// String 返回版本号字符串格式v1.0.0
func (v *Version) String() string {
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
}
// Compare 比较版本号
// 返回值:-1 表示当前版本小于目标版本0 表示相等1 表示大于
func (v *Version) Compare(other *Version) int {
if v.Major != other.Major {
if v.Major < other.Major {
return -1
}
return 1
}
if v.Minor != other.Minor {
if v.Minor < other.Minor {
return -1
}
return 1
}
if v.Patch != other.Patch {
if v.Patch < other.Patch {
return -1
}
return 1
}
return 0
}
// IsNewerThan 判断是否比目标版本新
func (v *Version) IsNewerThan(other *Version) bool {
return v.Compare(other) > 0
}
// IsOlderThan 判断是否比目标版本旧
func (v *Version) IsOlderThan(other *Version) bool {
return v.Compare(other) < 0
}
// ==================== 版本号获取 ====================
// GetCurrentVersion 获取当前版本号
// 优先级:硬编码版本号 > wails.json开发模式> 默认值
func GetCurrentVersion() string {
if AppVersion != "" {
log.Printf("[版本] 使用硬编码版本号: %s", AppVersion)
return AppVersion
}
version := getVersionFromWailsJSON()
if version != "" {
log.Printf("[版本] 从 wails.json 获取版本号: %s", version)
return version
}
log.Printf("[版本] 使用默认版本号: 0.0.1")
return "0.0.1"
}
// ==================== 配置文件读取 ====================
// getVersionFromWailsJSON 从 wails.json 读取版本号(仅开发模式使用)
func getVersionFromWailsJSON() string {
wd, err := os.Getwd()
if err != nil {
return ""
}
// 尝试当前目录
if version := readVersionFromFile(filepath.Join(wd, "wails.json")); version != "" {
return version
}
// 尝试父目录
if version := readVersionFromFile(filepath.Join(filepath.Dir(wd), "wails.json")); version != "" {
return version
}
return ""
}
// readVersionFromFile 从指定文件读取版本号
func readVersionFromFile(filePath string) string {
data, err := os.ReadFile(filePath)
if err != nil {
log.Printf("[版本] 读取文件失败: %s, 错误: %v", filePath, err)
return ""
}
var config WailsConfig
if err := json.Unmarshal(data, &config); err != nil {
log.Printf("[版本] 解析 JSON 失败: %s, 错误: %v", filePath, err)
return ""
}
if config.Version != "" {
log.Printf("[版本] 从文件读取版本号: %s -> %s", filePath, config.Version)
}
return config.Version
}

View File

@@ -0,0 +1,20 @@
package models
import "time"
// Authorization 授权信息
type Authorization struct {
ID int `gorm:"primaryKey" json:"id"` // 主键ID
LicenseCode string `gorm:"type:varchar(100);not null;uniqueIndex" json:"license_code"` // 授权码(唯一)
DeviceID string `gorm:"type:varchar(100);not null;index" json:"device_id"` // 设备IDMD5哈希
ActivatedAt time.Time `gorm:"not null" json:"activated_at"` // 激活时间
ExpiresAt *time.Time `gorm:"type:datetime" json:"expires_at"` // 过期时间可选nil表示永不过期
Status int `gorm:"type:tinyint;not null;default:1" json:"status"` // 状态1:有效 0:无效)
CreatedAt time.Time `gorm:"autoCreateTime:false" json:"created_at"` // 创建时间(由程序设置)
UpdatedAt time.Time `gorm:"autoUpdateTime:false" json:"updated_at"` // 更新时间(由程序设置)
}
// TableName 指定表名
func (Authorization) TableName() string {
return "sys_authorization_code"
}

View File

@@ -0,0 +1,24 @@
package models
import "time"
// SsqHistory 双色球历史开奖数据
type SsqHistory struct {
ID int `gorm:"primaryKey;column:id" json:"id"`
IssueNumber string `gorm:"type:varchar(20);not null;index;column:issue_number" json:"issue_number"`
OpenDate *time.Time `gorm:"type:date;column:open_date" json:"open_date"`
RedBall1 int `gorm:"type:tinyint;not null;column:red_ball_1" json:"red_ball_1"`
RedBall2 int `gorm:"type:tinyint;not null;column:red_ball_2" json:"red_ball_2"`
RedBall3 int `gorm:"type:tinyint;not null;column:red_ball_3" json:"red_ball_3"`
RedBall4 int `gorm:"type:tinyint;not null;column:red_ball_4" json:"red_ball_4"`
RedBall5 int `gorm:"type:tinyint;not null;column:red_ball_5" json:"red_ball_5"`
RedBall6 int `gorm:"type:tinyint;not null;column:red_ball_6" json:"red_ball_6"`
BlueBall int `gorm:"type:tinyint;not null;column:blue_ball" json:"blue_ball"`
CreatedAt time.Time `gorm:"autoCreateTime:false;column:created_at" json:"created_at"` // 创建时间(由程序设置)
UpdatedAt time.Time `gorm:"autoUpdateTime:false;column:updated_at" json:"updated_at"` // 更新时间(由程序设置)
}
// TableName 指定表名
func (SsqHistory) TableName() string {
return "ssq_history"
}

View File

@@ -0,0 +1,20 @@
package models
import "time"
// Version 版本信息
type Version struct {
ID int `gorm:"primaryKey" json:"id"` // 主键ID
Version string `gorm:"type:varchar(20);not null;uniqueIndex" json:"version"` // 版本号语义化版本如1.0.0
DownloadURL string `gorm:"type:varchar(500)" json:"download_url"` // 下载地址更新包下载URL
Changelog string `gorm:"type:text" json:"changelog"` // 更新日志Markdown格式
ForceUpdate int `gorm:"type:tinyint;not null;default:0" json:"force_update"` // 是否强制更新1:是 0:否)
ReleaseDate *time.Time `gorm:"type:date" json:"release_date"` // 发布日期
CreatedAt time.Time `gorm:"autoCreateTime:false" json:"created_at"` // 创建时间(由程序设置)
UpdatedAt time.Time `gorm:"autoUpdateTime:false" json:"updated_at"` // 更新时间(由程序设置)
}
// TableName 指定表名
func (Version) TableName() string {
return "sys_version"
}

View File

@@ -0,0 +1,76 @@
package repository
import (
"ssq-desk/internal/database"
"ssq-desk/internal/storage/models"
"time"
"gorm.io/gorm"
)
// AuthRepository 授权数据访问接口
type AuthRepository interface {
Create(auth *models.Authorization) error
Update(auth *models.Authorization) error
GetByLicenseCode(licenseCode string) (*models.Authorization, error)
GetByDeviceID(deviceID string) (*models.Authorization, error)
}
// SQLiteAuthRepository SQLite 授权数据访问实现
type SQLiteAuthRepository struct {
db *gorm.DB
}
// NewSQLiteAuthRepository 创建 SQLite 授权数据访问实例
func NewSQLiteAuthRepository() (AuthRepository, error) {
db := database.GetSQLite()
if db == nil {
return nil, gorm.ErrInvalidDB
}
// 自动迁移
err := db.AutoMigrate(&models.Authorization{})
if err != nil {
return nil, err
}
return &SQLiteAuthRepository{db: db}, nil
}
// Create 创建授权记录
func (r *SQLiteAuthRepository) Create(auth *models.Authorization) error {
now := time.Now()
if auth.CreatedAt.IsZero() {
auth.CreatedAt = now
}
if auth.UpdatedAt.IsZero() {
auth.UpdatedAt = now
}
return r.db.Create(auth).Error
}
// Update 更新授权记录
func (r *SQLiteAuthRepository) Update(auth *models.Authorization) error {
auth.UpdatedAt = time.Now()
return r.db.Save(auth).Error
}
// GetByLicenseCode 根据授权码查询
func (r *SQLiteAuthRepository) GetByLicenseCode(licenseCode string) (*models.Authorization, error) {
var auth models.Authorization
err := r.db.Where("license_code = ?", licenseCode).First(&auth).Error
if err != nil {
return nil, err
}
return &auth, nil
}
// GetByDeviceID 根据设备ID查询
func (r *SQLiteAuthRepository) GetByDeviceID(deviceID string) (*models.Authorization, error) {
var auth models.Authorization
err := r.db.Where("device_id = ? AND status = ?", deviceID, 1).First(&auth).Error
if err != nil {
return nil, err
}
return &auth, nil
}

View File

@@ -0,0 +1,128 @@
package repository
import (
"gorm.io/gorm"
"ssq-desk/internal/database"
"ssq-desk/internal/storage/models"
"time"
)
// MySQLSsqRepository MySQL 实现
type MySQLSsqRepository struct {
db *gorm.DB
}
// NewMySQLSsqRepository 创建 MySQL 仓库
func NewMySQLSsqRepository() (SsqRepository, error) {
db := database.GetMySQL()
if db == nil {
return nil, gorm.ErrInvalidDB
}
return &MySQLSsqRepository{db: db}, nil
}
// FindAll 查询所有历史数据
func (r *MySQLSsqRepository) FindAll() ([]models.SsqHistory, error) {
var histories []models.SsqHistory
err := r.db.Order("issue_number DESC").Find(&histories).Error
return histories, err
}
// FindByIssue 根据期号查询
func (r *MySQLSsqRepository) FindByIssue(issueNumber string) (*models.SsqHistory, error) {
var history models.SsqHistory
err := r.db.Where("issue_number = ?", issueNumber).First(&history).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return &history, err
}
// FindByRedBalls 根据红球查询(支持部分匹配)
func (r *MySQLSsqRepository) FindByRedBalls(redBalls []int) ([]models.SsqHistory, error) {
if len(redBalls) == 0 {
return r.FindAll()
}
var histories []models.SsqHistory
query := r.db
// 构建查询条件:红球在输入的红球列表中
for _, ball := range redBalls {
query = query.Where("red_ball_1 = ? OR red_ball_2 = ? OR red_ball_3 = ? OR red_ball_4 = ? OR red_ball_5 = ? OR red_ball_6 = ?",
ball, ball, ball, ball, ball, ball)
}
err := query.Order("issue_number DESC").Find(&histories).Error
return histories, err
}
// FindByRedAndBlue 根据红球和蓝球查询
func (r *MySQLSsqRepository) FindByRedAndBlue(redBalls []int, blueBall int, blueBallRange []int) ([]models.SsqHistory, error) {
var histories []models.SsqHistory
query := r.db
// 红球条件
if len(redBalls) > 0 {
for _, ball := range redBalls {
query = query.Where("red_ball_1 = ? OR red_ball_2 = ? OR red_ball_3 = ? OR red_ball_4 = ? OR red_ball_5 = ? OR red_ball_6 = ?",
ball, ball, ball, ball, ball, ball)
}
}
// 蓝球条件
if blueBall > 0 {
query = query.Where("blue_ball = ?", blueBall)
} else if len(blueBallRange) > 0 {
query = query.Where("blue_ball IN ?", blueBallRange)
}
err := query.Order("issue_number DESC").Find(&histories).Error
return histories, err
}
// Create 创建记录
func (r *MySQLSsqRepository) Create(history *models.SsqHistory) error {
now := time.Now()
if history.CreatedAt.IsZero() {
history.CreatedAt = now
}
if history.UpdatedAt.IsZero() {
history.UpdatedAt = now
}
return r.db.Create(history).Error
}
// BatchCreate 批量创建
func (r *MySQLSsqRepository) BatchCreate(histories []models.SsqHistory) error {
if len(histories) == 0 {
return nil
}
now := time.Now()
for i := range histories {
if histories[i].CreatedAt.IsZero() {
histories[i].CreatedAt = now
}
if histories[i].UpdatedAt.IsZero() {
histories[i].UpdatedAt = now
}
}
return r.db.CreateInBatches(histories, 100).Error
}
// GetLatestIssue 获取最新期号
func (r *MySQLSsqRepository) GetLatestIssue() (string, error) {
var history models.SsqHistory
err := r.db.Order("issue_number DESC").First(&history).Error
if err == gorm.ErrRecordNotFound {
return "", nil
}
return history.IssueNumber, err
}
// Count 统计总数
func (r *MySQLSsqRepository) Count() (int64, error) {
var count int64
err := r.db.Model(&models.SsqHistory{}).Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,136 @@
package repository
import (
"fmt"
"gorm.io/gorm"
"ssq-desk/internal/database"
"ssq-desk/internal/storage/models"
"time"
)
// SQLiteSsqRepository SQLite 实现
type SQLiteSsqRepository struct {
db *gorm.DB
}
// NewSQLiteSsqRepository 创建 SQLite 仓库
func NewSQLiteSsqRepository() (SsqRepository, error) {
db := database.GetSQLite()
if db == nil {
return nil, fmt.Errorf("SQLite 数据库未初始化或初始化失败")
}
// 自动迁移表结构(数据库初始化时已迁移,这里再次确保)
err := db.AutoMigrate(&models.SsqHistory{})
if err != nil {
return nil, fmt.Errorf("SQLite 表迁移失败: %v", err)
}
return &SQLiteSsqRepository{db: db}, nil
}
// FindAll 查询所有历史数据
func (r *SQLiteSsqRepository) FindAll() ([]models.SsqHistory, error) {
var histories []models.SsqHistory
err := r.db.Order("issue_number DESC").Find(&histories).Error
return histories, err
}
// FindByIssue 根据期号查询
func (r *SQLiteSsqRepository) FindByIssue(issueNumber string) (*models.SsqHistory, error) {
var history models.SsqHistory
err := r.db.Where("issue_number = ?", issueNumber).First(&history).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return &history, err
}
// FindByRedBalls 根据红球查询(支持部分匹配)
func (r *SQLiteSsqRepository) FindByRedBalls(redBalls []int) ([]models.SsqHistory, error) {
if len(redBalls) == 0 {
return r.FindAll()
}
var histories []models.SsqHistory
query := r.db
// 构建查询条件:红球在输入的红球列表中
for _, ball := range redBalls {
query = query.Where("red_ball_1 = ? OR red_ball_2 = ? OR red_ball_3 = ? OR red_ball_4 = ? OR red_ball_5 = ? OR red_ball_6 = ?",
ball, ball, ball, ball, ball, ball)
}
err := query.Order("issue_number DESC").Find(&histories).Error
return histories, err
}
// FindByRedAndBlue 根据红球和蓝球查询
func (r *SQLiteSsqRepository) FindByRedAndBlue(redBalls []int, blueBall int, blueBallRange []int) ([]models.SsqHistory, error) {
var histories []models.SsqHistory
query := r.db
// 红球条件
if len(redBalls) > 0 {
for _, ball := range redBalls {
query = query.Where("red_ball_1 = ? OR red_ball_2 = ? OR red_ball_3 = ? OR red_ball_4 = ? OR red_ball_5 = ? OR red_ball_6 = ?",
ball, ball, ball, ball, ball, ball)
}
}
// 蓝球条件
if blueBall > 0 {
query = query.Where("blue_ball = ?", blueBall)
} else if len(blueBallRange) > 0 {
query = query.Where("blue_ball IN ?", blueBallRange)
}
err := query.Order("issue_number DESC").Find(&histories).Error
return histories, err
}
// Create 创建记录
func (r *SQLiteSsqRepository) Create(history *models.SsqHistory) error {
now := time.Now()
if history.CreatedAt.IsZero() {
history.CreatedAt = now
}
if history.UpdatedAt.IsZero() {
history.UpdatedAt = now
}
return r.db.Create(history).Error
}
// BatchCreate 批量创建
func (r *SQLiteSsqRepository) BatchCreate(histories []models.SsqHistory) error {
if len(histories) == 0 {
return nil
}
now := time.Now()
for i := range histories {
if histories[i].CreatedAt.IsZero() {
histories[i].CreatedAt = now
}
if histories[i].UpdatedAt.IsZero() {
histories[i].UpdatedAt = now
}
}
return r.db.CreateInBatches(histories, 100).Error
}
// GetLatestIssue 获取最新期号
func (r *SQLiteSsqRepository) GetLatestIssue() (string, error) {
var history models.SsqHistory
err := r.db.Order("issue_number DESC").First(&history).Error
if err == gorm.ErrRecordNotFound {
return "", nil
}
return history.IssueNumber, err
}
// Count 统计总数
func (r *SQLiteSsqRepository) Count() (int64, error) {
var count int64
err := r.db.Model(&models.SsqHistory{}).Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,25 @@
package repository
import (
"ssq-desk/internal/storage/models"
)
// SsqRepository 双色球数据仓库接口
type SsqRepository interface {
// FindAll 查询所有历史数据
FindAll() ([]models.SsqHistory, error)
// FindByIssue 根据期号查询
FindByIssue(issueNumber string) (*models.SsqHistory, error)
// FindByRedBalls 根据红球查询(支持部分匹配)
FindByRedBalls(redBalls []int) ([]models.SsqHistory, error)
// FindByRedAndBlue 根据红球和蓝球查询
FindByRedAndBlue(redBalls []int, blueBall int, blueBallRange []int) ([]models.SsqHistory, error)
// Create 创建记录
Create(history *models.SsqHistory) error
// BatchCreate 批量创建
BatchCreate(histories []models.SsqHistory) error
// GetLatestIssue 获取最新期号
GetLatestIssue() (string, error)
// Count 统计总数
Count() (int64, error)
}

38
main.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:web/dist
var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
// Create application with options
err := wails.Run(&options.App{
Title: "SSQ-Desk",
Width: 1400,
Height: 900,
MinWidth: 1000,
MinHeight: 600,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}
}

16
wails.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "ssq-desk",
"outputfilename": "ssq-desk",
"frontend:dir": "web",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"wailsjsdir": "./web/src",
"buildflags": "-tags=sqlite_omit_load_extension",
"author": {
"name": "JueChen",
"email": "237809796@qq.com"
}
}

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSQ-Desk</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1462
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
web/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "ssq-desk-web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@arco-design/web-vue": "^2.54.0",
"vue": "^3.5.26"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.3",
"vite": "^7.3.0"
}
}

1
web/package.json.md5 Normal file
View File

@@ -0,0 +1 @@
67c87d1d32114e5c095571d4f62523e1

136
web/src/App.vue Normal file
View File

@@ -0,0 +1,136 @@
<template>
<div class="app">
<a-layout class="layout">
<a-layout-header class="header">
<div class="header-content">
<h1 class="title">SSQ-Desk</h1>
<a-menu
mode="horizontal"
:selected-keys="[currentPage]"
@menu-item-click="handleMenuClick"
class="nav-menu"
>
<a-menu-item key="query">
<template #icon>
<icon-search />
</template>
查询
</a-menu-item>
<a-menu-item key="auth">
<template #icon>
<icon-lock />
</template>
授权
</a-menu-item>
</a-menu>
<div class="header-actions">
<span class="version-text">当前版本{{ version.currentVersion || '加载中...' }}</span>
</div>
</div>
</a-layout-header>
<a-layout-content class="content">
<QueryPage v-if="currentPage === 'query'" />
<AuthPage v-if="currentPage === 'auth'" />
</a-layout-content>
</a-layout>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { IconSearch, IconLock } from '@arco-design/web-vue/es/icon'
import QueryPage from './views/query/QueryPage.vue'
import AuthPage from './views/auth/AuthPage.vue'
import { useVersion } from './composables/useVersion'
// ==================== 导航 ====================
const currentPage = ref('query')
const handleMenuClick = (key) => {
currentPage.value = key
}
// ==================== 版本相关 ====================
const version = useVersion()
// 应用启动时不再重复调用useVersion 内部已处理
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
height: 100vh;
}
.app {
width: 100%;
height: 100vh;
}
.layout {
height: 100%;
}
.header {
background: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
padding: 0 !important;
height: 48px !important;
line-height: 48px;
}
.header.arco-layout-header {
padding: 0 !important;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 100%;
flex-wrap: nowrap;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.nav-menu {
background: transparent;
border: none;
flex-shrink: 0;
}
.version-text {
font-size: 14px;
color: var(--color-text-1);
margin-right: 16px;
}
.header-actions {
display: flex;
align-items: center;
}
.content {
overflow-y: auto;
}
</style>

Some files were not shown because too many files have changed in this diff Show More