首次提交:初始化项目代码

This commit is contained in:
sunct
2026-07-31 14:44:48 +08:00
commit a4fe393571
119 changed files with 29105 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git/
data/
logs/
*.log
.vscode/
.DS_Store
+50
View File
@@ -0,0 +1,50 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
dist/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.txt
coverage.html
# Go workspace file
go.work
# Dependency directories (remove the comment below to include it)
# vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Project data
data/
logs/
*.log
*.db
*.sqlite
*.sqlite3
# Environment
.env
.env.local
.env.*.local
# Uploads
uploads/
static/uploads/
+1
View File
@@ -0,0 +1 @@
# 请将伪静态规则或自定义Apache配置填写到此处
+40
View File
@@ -0,0 +1,40 @@
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.version=$(date +%Y%m%d%H%M%S)" \
-o /out/resume \
cmd/server/main.go
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata && \
addgroup -S app && adduser -S app -G app
ENV TZ=Asia/Shanghai \
GIN_MODE=release
WORKDIR /app
COPY --from=builder --chown=app:app /out/resume ./resume
COPY --from=builder --chown=app:app /src/templates ./templates
COPY --from=builder --chown=app:app /src/config ./config
COPY --from=builder --chown=app:app /src/static ./static
RUN mkdir -p /app/data /app/logs && \
chown -R app:app /app
USER app
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8090/ || exit 1
CMD ["./resume"]
+370
View File
@@ -0,0 +1,370 @@
# Resume Platform · 智能简历平台
[![Go Version](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go)](https://go.dev/)
[![Gin](https://img.shields.io/badge/Gin-1.9.1-00ADD8?style=flat-square)](https://gin-gonic.com/)
[![SQLite](https://img.shields.io/badge/SQLite-3.x-003B57?style=flat-square&logo=sqlite)](https://www.sqlite.org/)
[![License](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](#贡献指南)
> 基于 Go + Gin + SQLite 的现代化开源简历平台,内置 AI 面试助手、多模板支持、多人简历管理,让你的个人主页脱颖而出。
---
## ✨ 功能特性
### 📄 简历管理
- **多人简历支持** — 每个人独立访问路由(如 `/zhangsan``/lisi`),支持人员绑定与解绑
- **多模板主题** — 内置 7 种精美模板(modern / classic / fresh / minimalist / professional / tech / project
- **实时编辑** — 所见即所得的编辑体验,自动保存防止丢失
- **访问密码** — 支持为简历设置独立访问密码,保护隐私
- **数据脱敏** — 公开展示时自动脱敏手机号、邮箱等敏感信息
### 🤖 AI 智能面试
- **智能出题** — 基于简历内容或关键词自动生成面试题,支持选择题、填空题、简答题、算法题
- **多 AI 提供商** — 接入 DeepSeek / 豆包 / 讯飞星火 / 通义千问,自由切换
- **关键词驱动** — 智能提取简历技能作为关键词,支持别名扩展,每次生成随机不重复
- **答题记录** — 完整记录每次答题情况,支持查看历史、重练错题
- **收藏 & 错题本** — 一键收藏好题,错题自动入库,针对性复习
### 🎨 作品集展示
- **独立作品集页** — 每个用户拥有专属作品集展示页面
- **项目关联** — 支持从简历项目中导入,也可独立创建
- **分类筛选** — 按技术栈、类型快速筛选项目
- **首页精选** — 可设置首页展示的精选项目
### 🛡️ 安全保障
- **后台隐藏路径** — 自定义管理后台 URL,降低被探测风险
- **登录验证码** — 图形验证码防止机器人暴力破解
- **登录限流** — 基于 IP 的登录次数限制,超出自动锁定
- **AES 加密** — 数据库支持 AES-GCM 加密存储,敏感数据更安全
- **PBKDF2 密钥派生** — 使用 PBKDF2-SHA256 派生加密密钥
### 📊 管理后台
- **仪表盘** — 数据总览,快速了解平台运行状态
- **用户管理** — 完整的用户 CRUD,支持状态管理
- **简历管理** — 统一管理所有简历,支持绑定/解绑人员
- **答题管理** — 查看用户答题记录、学习进度
- **菜单管理** — 自定义后台导航菜单
- **系统设置** — 站点名称、Logo、SEO 等配置可视化管理
- **全局搜索** — 一键搜索用户、简历、作品集
---
## 🏗️ 技术栈
| 类别 | 技术 | 版本 |
|------|------|------|
| 语言 | Go | 1.25+ |
| Web 框架 | Gin | v1.9.1 |
| 数据库 | SQLite (modernc.org/sqlite) | v1.26.0 |
| AI Agent | CloudWeGo Eino | v0.9.12 |
| 配置管理 | Viper | v1.18.2 |
| 验证码 | base64Captcha | v1.3.2 |
| 前端 | HTML Template + Tailwind CSS + Font Awesome | — |
| 部署 | Docker / Docker Compose | — |
---
## 📁 项目结构
```
resume-platform/
├── cmd/
│ ├── server/ # 主服务入口
│ │ └── main.go
│ └── checkdb/ # 数据库检查工具
│ └── main.go
├── config/
│ └── config.yaml # 应用配置文件
├── internal/
│ ├── agent/ # AI Agent 层(Eino
│ │ ├── config.go
│ │ └── tools.go # 题目生成、答案解析等工具
│ ├── ai/ # AI 提供商适配层
│ │ ├── provider.go # 统一接口定义
│ │ ├── deepseek.go
│ │ ├── doubao.go
│ │ ├── spark.go
│ │ └── tongyi.go
│ ├── handler/ # HTTP 处理器
│ │ ├── admin.go # 管理后台
│ │ ├── ai.go # AI 相关接口
│ │ ├── chat.go # AI 对话接口
│ │ ├── document.go # 文档解析接口
│ │ ├── response.go # 统一响应格式
│ │ └── resume.go # 简历/作品集/答题 API
│ ├── model/ # 数据模型
│ │ ├── resume.go # 简历、用户、题目、作品集等
│ │ └── document.go
│ ├── parser/ # 简历解析
│ │ └── parser.go
│ ├── repository/ # 数据访问层
│ │ ├── interface.go # 仓库接口定义
│ │ └── sqlite.go # SQLite 实现
│ ├── router/ # 路由配置
│ │ ├── router.go # 路由总入口
│ │ ├── admin.go # 管理后台路由
│ │ ├── api.go # REST API 路由
│ │ └── frontend.go # 前端页面路由
│ ├── security/ # 安全模块
│ │ ├── captcha.go # 验证码
│ │ └── rate_limiter.go # 登录限流
│ └── service/ # 业务逻辑层
│ ├── ai_service.go # AI 服务
│ ├── chat.go # 对话服务
│ ├── document.go # 文档服务
│ ├── resume.go # 简历服务
│ └── vector.go # 向量服务
├── pkg/
│ └── config/ # 公共配置包
│ └── config.go
├── static/ # 静态资源
│ ├── images/
│ └── js/
├── templates/ # HTML 模板
│ ├── admin/ # 管理后台模板
│ └── web/ # 前端展示模板
├── data/ # 数据库文件(运行时生成)
├── logs/ # 日志文件(运行时生成)
├── Dockerfile
├── docker-compose.yml
├── go.mod
└── go.sum
```
---
## 🚀 快速开始
### 环境要求
- **Go 1.25+**(推荐使用最新稳定版)
-**Docker** / **Docker Compose**
### 本地运行
```bash
# 1. 克隆项目
git clone https://github.com/yourusername/resume-platform.git
cd resume-platform
# 2. 安装依赖
go mod download
# 3. 运行服务
go run cmd/server/main.go
```
服务启动后:
| 地址 | 说明 |
|------|------|
| http://localhost:8090 | 产品首页 |
| http://localhost:8090/default | 默认简历示例 |
| http://localhost:8090/my-secret-admin-123 | 管理后台(默认路径) |
> **默认账号**`admin` / `admin123456`
>
> ⚠️ 生产环境请务必修改默认密码和后台路径!
### Docker 部署
#### 使用 Docker Compose(推荐)
```bash
docker-compose up -d
```
#### 手动构建
```bash
docker build -t resume-platform .
docker run -d \
-p 8090:8090 \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
--name resume-platform \
resume-platform
```
---
## ⚙️ 配置说明
配置文件位于 `config/config.yaml`,所有配置项均支持通过环境变量覆盖。
```yaml
server:
host: "0.0.0.0" # 监听地址
port: 8090 # 监听端口
mode: "release" # Gin 模式:debug / release
database:
type: "sqlite" # 数据库类型(目前仅支持 SQLite)
path: "./data/resume.db" # 数据库文件路径
password: "resume@2024" # 数据库加密密码
auth:
enabled: true # 是否启用后台认证
username: "admin" # 管理员用户名
password: "admin123456" # 管理员密码
admin_path: "/my-secret-admin-123" # 后台路径(建议修改)
max_login_attempts: 5 # 最大登录尝试次数
lockout_duration: 15 # 锁定时长(分钟)
template:
default: "modern" # 默认简历模板
log:
level: "info" # 日志级别
output: "./logs/app.log" # 日志输出路径
ai:
provider: "spark" # AI 提供商:spark / tongyi / doubao / deepseek
enabled: true # 是否启用 AI 功能
spark: # 讯飞星火配置
api_password: ""
base_url: ""
model: "lite"
max_tokens: 1024
timeout: 30
# 其他 AI 提供商配置...
```
---
## 🔌 API 概览
### 简历接口
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/resume/:id` | 获取单份简历 |
| `GET` | `/api/resume/by-route/:route` | 按路由获取简历 |
| `POST` | `/api/resume` | 创建简历 |
| `PUT` | `/api/resume/:id` | 更新简历 |
| `DELETE` | `/api/resume/:id` | 删除简历 |
| `GET` | `/api/user/resumes` | 获取指定用户的简历列表 |
### AI 答题接口
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/ai/agent/generate-questions` | 生成面试题 |
| `POST` | `/api/ai/agent/submit-answers` | 提交答案并评分 |
| `POST` | `/api/ai/agent/evaluate-answers` | 评估答案 |
### 收藏 & 错题
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/favorite` | 添加收藏/错题 |
| `GET` | `/api/favorite` | 获取收藏/错题列表 |
| `DELETE` | `/api/favorite/:id` | 删除收藏/错题 |
| `PUT` | `/api/favorite/:id/toggle` | 切换收藏状态 |
### 作品集接口
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/portfolio` | 获取作品集列表 |
| `POST` | `/api/portfolio` | 创建作品集项目 |
| `GET` | `/api/portfolio/:id` | 获取单个项目 |
| `PUT` | `/api/portfolio/:id` | 更新项目 |
| `DELETE` | `/api/portfolio/:id` | 删除项目 |
### 管理后台
所有管理接口前缀为 `{admin_path}/`(默认 `/my-secret-admin-123/`)。
---
## 🧩 模板系统
内置 7 种精美模板,支持随时切换:
| 模板 | 风格 | 适用场景 |
|------|------|----------|
| `modern` | 现代简约 | 通用型,推荐首选 |
| `classic` | 经典商务 | 传统行业、资深岗位 |
| `fresh` | 清新活力 | 应届生、设计岗 |
| `minimalist` | 极简主义 | 追求简洁表达 |
| `professional` | 专业职业 | 管理岗、专业岗 |
| `tech` | 技术导向 | 程序员、工程师 |
| `project` | 项目展示 | 突出项目经验 |
> 模板文件位于 `templates/web/`,可基于现有模板自定义新主题。
---
## 🛠️ 开发指南
### 添加新模板
1.`templates/web/` 目录下创建 `your-template.html`
2. 继承 `base.html` 的结构,使用 Go 模板语法访问 `.Resume` 数据
3.`resume.Template` 字段设置模板名称即可
### 添加新的 AI 提供商
1.`internal/ai/` 下创建 `yourprovider.go`
2. 实现 `AIProvider` 接口
3.`provider.go``NewProvider` 工厂中注册
### 数据库迁移
项目使用 `CREATE TABLE IF NOT EXISTS` + `migrateSchema` 机制自动处理表结构变更。新增列时在 `migrateSchema` 函数中添加对应 `ALTER TABLE ADD COLUMN` 语句即可,程序启动时自动执行。
### 代码规范
```bash
# 格式化代码
gofmt -w .
# 静态检查
go vet ./...
# 构建检查
go build ./...
```
---
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
### 贡献流程
1. **Fork** 本仓库
2. 创建特性分支:`git checkout -b feature/AmazingFeature`
3. 提交更改:`git commit -m 'Add some AmazingFeature'`
4. 推送到分支:`git push origin feature/AmazingFeature`
5. 打开 **Pull Request**
### 提交规范
- 使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范
- 示例:`feat: add dark mode support``fix: correct user binding display`
---
## 📜 开源协议
本项目基于 **MIT License** 开源,详见 [LICENSE](LICENSE) 文件。
---
## ⭐ 支持项目
如果这个项目对你有帮助,欢迎:
- 点个 Star ⭐️ 让更多人看到
- 分享给身边需要的朋友
- 提交 Issue / PR 一起完善
---
<div align="center">
<p>Made with ❤️ by Resume Platform Team</p>
</div>
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"fmt"
"os"
"regexp"
"strings"
)
func addAuthorToFile(filePath string) error {
content, err := os.ReadFile(filePath)
if err != nil {
return err
}
lines := strings.Split(string(content), "\n")
var result []string
var commentBuffer []string
inComment := false
funcPattern := regexp.MustCompile(`^func\s+(\([^)]+\)\s+)?\w+\s*\(`)
for i := 0; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") && !strings.HasPrefix(trimmed, "//go:") {
if strings.Contains(trimmed, "@author") {
for _, c := range commentBuffer {
result = append(result, c)
}
commentBuffer = nil
result = append(result, line)
inComment = false
continue
}
commentBuffer = append(commentBuffer, line)
inComment = true
continue
}
if inComment && len(commentBuffer) > 0 && funcPattern.MatchString(trimmed) {
for _, c := range commentBuffer {
result = append(result, c)
}
indent := ""
for _, ch := range line {
if ch == ' ' || ch == '\t' {
indent += string(ch)
} else {
break
}
}
result = append(result, indent+"// @author sunct")
result = append(result, line)
commentBuffer = nil
inComment = false
continue
}
if len(commentBuffer) > 0 {
for _, c := range commentBuffer {
result = append(result, c)
}
commentBuffer = nil
}
inComment = false
result = append(result, line)
}
if len(commentBuffer) > 0 {
for _, c := range commentBuffer {
result = append(result, c)
}
}
output := strings.Join(result, "\n")
return os.WriteFile(filePath, []byte(output), 0644)
}
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run add_author.go <file1.go> [file2.go ...]")
os.Exit(1)
}
for _, file := range os.Args[1:] {
fmt.Printf("Processing: %s ... ", file)
err := addAuthorToFile(file)
if err != nil {
fmt.Printf("ERROR: %v\n", err)
} else {
fmt.Println("OK")
}
}
}
+74
View File
@@ -0,0 +1,74 @@
// Package main 数据库检查工具,用于调试查看 system_config 表结构和数据
package main
import (
"database/sql"
"fmt"
"log"
_ "modernc.org/sqlite"
)
// main 数据库检查程序入口,打印 system_config 表结构和记录
// @author sunct
func main() {
db, err := sql.Open("sqlite", "./data/resume.db")
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
fmt.Println("=== system_config Table ===")
rows, err := db.Query("PRAGMA table_info(system_config)")
if err != nil {
log.Fatalf("Failed to get table info: %v", err)
}
defer rows.Close()
fmt.Println("Table Structure:")
for rows.Next() {
var cid int
var name, colType string
var notNull, pk int
var dfltValue interface{}
err := rows.Scan(&cid, &name, &colType, &notNull, &dfltValue, &pk)
if err != nil {
log.Printf("Failed to scan column: %v", err)
continue
}
fmt.Printf(" %d: %s (%s), notNull: %d, pk: %d, default: %v\n", cid, name, colType, notNull, pk, dfltValue)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM system_config").Scan(&count)
if err != nil {
log.Fatalf("Failed to count records: %v", err)
}
fmt.Printf("\nTotal records in system_config: %d\n", count)
rows2, err := db.Query("SELECT * FROM system_config")
if err != nil {
log.Fatalf("Failed to query records: %v", err)
}
defer rows2.Close()
fmt.Println("\nSystem Config Records:")
for rows2.Next() {
var id, websiteDomain, websiteLogo, logoPath, websiteTitle, websiteDesc, websiteDescription, websiteKeywords, seoKeywords, seoDescription, adminEmail, adminName, databasePath, createdAt, updatedAt string
var adminPageSize int
err := rows2.Scan(&id, &websiteDomain, &websiteLogo, &logoPath, &websiteTitle, &websiteDesc, &websiteDescription, &websiteKeywords, &seoKeywords, &seoDescription, &adminEmail, &adminName, &databasePath, &adminPageSize, &createdAt, &updatedAt)
if err != nil {
log.Printf("Failed to scan record: %v", err)
continue
}
fmt.Printf("ID: %s\n", id)
fmt.Printf(" WebsiteDomain: %s\n", websiteDomain)
fmt.Printf(" WebsiteTitle: %s\n", websiteTitle)
fmt.Printf(" LogoPath: %s\n", logoPath)
fmt.Printf(" AdminPageSize: %d\n", adminPageSize)
}
if err := rows2.Err(); err != nil {
log.Printf("Rows error: %v", err)
}
}
+31
View File
@@ -0,0 +1,31 @@
// Package main 是简历平台服务的入口程序
// 负责调用 bootstrap 启动服务
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"resume-platform/internal/bootstrap"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
if err := bootstrap.Run(ctx); err != nil {
fmt.Printf("Server failed to start: %v\n", err)
os.Exit(1)
}
}
+60
View File
@@ -0,0 +1,60 @@
server:
host: "0.0.0.0"
port: 8090
mode: "release"
debug: true
database:
type: "sqlite"
path: "./data/resume.db"
password: "resume@2024"
auth:
enabled: true
username: "admin"
password: "admin123456"
admin_path: "/my-secret-admin-123"
max_login_attempts: 5
lockout_duration: 15
template:
default: "modern"
log:
level: "info"
output: "./logs/app.log"
ai:
provider: "spark"
enabled: true
spark:
app_id: "a146c363"
api_key: "1ba519d8cfb20f2732424f2a5ea8e630"
api_secret: "OTAwZGI3YjFjZjM2NmZmMzE0YjQxZWM2"
api_password: "OjAdJyOqCVmVYUFyaQJq:GQZNWpiVJTytmiSWuaTq"
base_url: "wss://spark-api.xf-yun.com/v1.1/chat"
model: "lite"
max_tokens: 10000
timeout: 30
web_search: false
show_ref_label: false
tongyi:
api_key: ""
base_url: "https://dashscope.aliyuncs.com/api/v1"
model: "qwen-turbo"
max_tokens: 10000
timeout: 30
doubao:
api_key: ""
base_url: "https://api.doubao.com/v1"
model: "Doubao"
max_tokens: 10000
timeout: 30
deepseek:
api_key: ""
base_url: "https://api.deepseek.com/v1"
model: "deepseek-chat"
max_tokens: 10000
timeout: 30
+29
View File
@@ -0,0 +1,29 @@
version: '3.8'
services:
resume:
build: .
container_name: resume-platform
restart: unless-stopped
ports:
- "8090:8090"
volumes:
- ./data:/app/data
- ./logs:/app/logs
- ./config:/app/config:ro
- ./static:/app/static:ro
environment:
- TZ=Asia/Shanghai
- GIN_MODE=release
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
networks:
- resume-network
networks:
resume-network:
driver: bridge
+92
View File
@@ -0,0 +1,92 @@
module resume-platform
go 1.25
require (
github.com/cloudwego/eino v0.9.12
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mojocn/base64Captcha v1.3.2
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.18.2
)
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eino-contrib/jsonschema v1.0.3 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.6.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)
require (
github.com/bytedance/sonic v1.15.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/crypto v0.31.0
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/sqlite v1.26.0
)
+274
View File
@@ -0,0 +1,274 @@
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0=
github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.9.12 h1:mHAMo5k7GdvnVD8Lc2sLyfpkxEm0S/y3PkEMhsSYt78=
github.com/cloudwego/eino v0.9.12/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM=
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
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/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
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/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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.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.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mojocn/base64Captcha v1.3.2 h1:Ouwjb2jFriltNdKZ1Ud1uu26Dv4+YVsoIw+u4navOmg=
github.com/mojocn/base64Captcha v1.3.2/go.mod h1:wAQCKEc5bDujxKRmbT6/vTnTt5CjStQ8bRfPWUuz/iY=
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75 h1:TbGuee8sSq15Iguxu4deQ7+Bqq/d2rsQejGcEtADAMQ=
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
+239
View File
@@ -0,0 +1,239 @@
// Package agent AI Agent 模块,基于 Eino 框架实现简历优化智能体
package agent
import (
"context"
"resume-platform/pkg/config"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino-ext/components/model/openai"
)
// ResumeAgentConfig 简历 Agent 配置
type ResumeAgentConfig struct {
ChatModel *openai.ChatModel // 聊天模型实例
Provider string // AI 供应商名称
BaseURL string // 接口地址
APIKey string // API Key
Model string // 模型名称
MaxTokens int // 最大 token 数
Temperature float64 // 温度参数
}
// ResumeAgent 简历优化智能体
type ResumeAgent struct {
agent adk.Agent // Eino Agent 实例
config *ResumeAgentConfig // Agent 配置
}
// NewResumeAgent 创建简历优化智能体
// @param ctx 上下文
// @param cfg AI 配置
// @return *ResumeAgent 智能体实例
// @return error 创建错误
// @author sunct
func NewResumeAgent(ctx context.Context, cfg *config.AIConfig) (*ResumeAgent, error) {
chatModel, err := createChatModel(ctx, cfg)
if err != nil {
return nil, err
}
tools := []tool.BaseTool{
NewPolishTool(),
NewAnalyzeTool(),
NewKeywordExtractTool(),
NewGenerateQuestionsTool(),
}
maxTokens := getMaxTokens(cfg)
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "ResumeAgent",
Description: "专业的简历优化智能体,能够分析简历内容、提供优化建议、提取关键词,帮助用户打造高质量简历。",
Instruction: "你是一位专业的简历优化专家。请根据用户的请求,使用可用的工具来帮助用户优化简历。\n\n可用工具:\n1. polish_resume - 优化简历内容\n2. analyze_resume - 分析简历并提供评估\n3. extract_keywords - 从简历中提取关键词\n4. generate_interview_questions - 根据简历内容生成面试题目\n\n请根据用户的具体需求选择合适的工具。",
Model: chatModel,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: tools,
},
},
MaxIterations: 20,
})
if err != nil {
return nil, err
}
return &ResumeAgent{
agent: agent,
config: &ResumeAgentConfig{
ChatModel: chatModel,
Provider: cfg.Provider,
Model: getModelName(cfg),
MaxTokens: maxTokens,
},
}, nil
}
// createChatModel 根据配置创建 OpenAI 兼容的聊天模型
// @param ctx 上下文
// @param cfg AI 配置
// @return *openai.ChatModel 聊天模型实例
// @return error 创建错误
// @author sunct
func createChatModel(ctx context.Context, cfg *config.AIConfig) (*openai.ChatModel, error) {
var apiKey, baseURL, model string
switch cfg.Provider {
case "spark":
apiKey = cfg.Spark.APIKey
if apiKey == "" {
parts := splitPassword(cfg.Spark.APIPassword)
if len(parts) == 2 {
apiKey = parts[0]
}
}
baseURL = cfg.Spark.BaseURL
model = getSparkModel(cfg.Spark.Model)
case "tongyi":
apiKey = cfg.Tongyi.APIKey
baseURL = cfg.Tongyi.BaseURL
model = cfg.Tongyi.Model
case "doubao":
apiKey = cfg.Doubao.APIKey
baseURL = cfg.Doubao.BaseURL
model = cfg.Doubao.Model
case "deepseek":
apiKey = cfg.DeepSeek.APIKey
baseURL = cfg.DeepSeek.BaseURL
model = cfg.DeepSeek.Model
default:
apiKey = cfg.Spark.APIKey
if apiKey == "" {
parts := splitPassword(cfg.Spark.APIPassword)
if len(parts) == 2 {
apiKey = parts[0]
}
}
baseURL = cfg.Spark.BaseURL
model = getSparkModel(cfg.Spark.Model)
}
maxTokens := getMaxTokens(cfg)
return openai.NewChatModel(ctx, &openai.ChatModelConfig{
Model: model,
APIKey: apiKey,
BaseURL: baseURL + "/chat/completions",
MaxTokens: &maxTokens,
Temperature: &temperature,
})
}
var temperature = float32(0.7)
// getSparkModel 获取讯飞星火模型全名
// @param model 模型简称(lite/pro/max
// @return string 完整模型名
// @author sunct
func getSparkModel(model string) string {
switch model {
case "lite":
return "spark-lite"
case "pro":
return "spark-pro"
case "max":
return "spark-max"
default:
return "spark-lite"
}
}
// getModelName 根据供应商获取模型名称
// @param cfg AI 配置
// @return string 模型名
// @author sunct
func getModelName(cfg *config.AIConfig) string {
switch cfg.Provider {
case "spark":
return getSparkModel(cfg.Spark.Model)
case "tongyi":
return cfg.Tongyi.Model
case "doubao":
return cfg.Doubao.Model
case "deepseek":
return cfg.DeepSeek.Model
default:
return "spark-lite"
}
}
// getMaxTokens 根据供应商获取最大 token 数
// @param cfg AI 配置
// @return int 最大 token 数
// @author sunct
func getMaxTokens(cfg *config.AIConfig) int {
switch cfg.Provider {
case "spark":
return cfg.Spark.MaxTokens
case "tongyi":
return cfg.Tongyi.MaxTokens
case "doubao":
return cfg.Doubao.MaxTokens
case "deepseek":
return cfg.DeepSeek.MaxTokens
default:
return cfg.Spark.MaxTokens
}
}
// splitPassword 按冒号分割密码字符串,提取 Key 和 Secret
// @param password 密码字符串
// @return []string 分割结果
// @author sunct
func splitPassword(password string) []string {
for i, c := range password {
if c == ':' {
return []string{password[:i], password[i+1:]}
}
}
return []string{password}
}
// Run 执行 Agent 查询,获取最终回答
// @param ctx 上下文
// @param query 用户查询
// @return string Agent 回答内容
// @return error 执行错误
// @author sunct
func (a *ResumeAgent) Run(ctx context.Context, query string) (string, error) {
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: a.agent})
iter := runner.Query(ctx, query)
var result string
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Output != nil {
if msgOutput := event.Output.MessageOutput; msgOutput != nil {
msg, err := msgOutput.GetMessage()
if err == nil && msg != nil {
result = msg.Content
}
}
}
}
return result, nil
}
// GetProvider 获取当前使用的 AI 供应商名称
// @return string 供应商标识
// @author sunct
func (a *ResumeAgent) GetProvider() string {
return a.config.Provider
}
+827
View File
@@ -0,0 +1,827 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"math/rand"
"strings"
"time"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type PolishTool struct{}
func NewPolishTool() *PolishTool {
return &PolishTool{}
}
func (t *PolishTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "polish_resume",
Desc: "优化简历内容,包括个人简介、工作经历、项目描述等。输入需要优化的文本和字段类型,返回优化后的内容。",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"text": {
Desc: "需要优化的简历文本内容",
Required: true,
Type: schema.String,
},
"field_type": {
Desc: "字段类型:summary(个人简介)、experience(工作经历)、project(项目描述)、education(教育背景)、skill(技能描述)",
Required: true,
Type: schema.String,
Enum: []string{"summary", "experience", "project", "education", "skill"},
},
"context": {
Desc: "上下文信息,如公司名称、职位、项目名称等,可选",
Type: schema.String,
},
}),
}, nil
}
func (t *PolishTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
var args struct {
Text string `json:"text"`
FieldType string `json:"field_type"`
Context string `json:"context"`
}
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
}
if args.Text == "" {
return "", fmt.Errorf("text is required")
}
prompt := buildPolishPrompt(args.Text, args.FieldType, args.Context)
result := fmt.Sprintf(`{"polished_text": "%s", "suggestions": ["使用了更专业的行业术语", "突出了量化成果", "优化了表达结构"]}`,
escapeJSON(prompt))
return result, nil
}
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "\\r")
return s
}
func buildPolishPrompt(text, fieldType, context string) string {
var fieldName string
var requirements string
switch fieldType {
case "summary":
fieldName = "个人简介"
requirements = `1. 突出核心竞争力和独特价值
2. 使用行动动词和量化成果
3. 保持专业但不过于生硬
4. 控制在150字以内
5. 使用STAR法则描述关键成就`
case "experience":
fieldName = "工作经历"
requirements = `1. 使用STAR法则(情境-任务-行动-结果)
2. 突出量化成果和数据指标
3. 使用专业术语和行业关键词
4. 突出个人贡献而非团队成果
5. 强调技术能力和解决问题的能力`
case "project":
fieldName = "项目描述"
requirements = `1. 清晰描述技术架构和技术栈
2. 突出个人技术贡献和解决的难题
3. 量化项目成果和影响
4. 使用专业技术术语
5. 说明项目复杂度和创新性`
case "education":
fieldName = "教育背景"
requirements = `1. 突出专业相关性
2. 强调学术成绩和荣誉
3. 描述相关课程和项目
4. 保持简洁专业`
case "skill":
fieldName = "技能描述"
requirements = `1. 按技术栈分类整理
2. 标注熟练程度
3. 突出核心竞争力
4. 与目标职位匹配`
default:
fieldName = "文本内容"
requirements = `1. 语言更加专业、精炼
2. 突出重点和亮点
3. 使用适当的行业术语
4. 格式清晰易读`
}
prompt := fmt.Sprintf(`你是一位专业的简历优化专家。请优化以下%s内容:
原文:
%s`, fieldName, text)
if context != "" {
prompt += fmt.Sprintf(`
上下文信息:
%s`, context)
}
prompt += fmt.Sprintf(`
优化要求:
%s
请直接输出优化后的内容,不要包含其他解释。`, requirements)
return prompt
}
type AnalyzeTool struct{}
func NewAnalyzeTool() *AnalyzeTool {
return &AnalyzeTool{}
}
func (t *AnalyzeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "analyze_resume",
Desc: "分析简历内容,提供专业的评估和改进建议。输入简历文本,返回分析报告。",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"resume_text": {
Desc: "简历文本内容",
Required: true,
Type: schema.String,
},
"target_position": {
Desc: "目标职位,可选",
Type: schema.String,
},
}),
}, nil
}
func (t *AnalyzeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
var args struct {
ResumeText string `json:"resume_text"`
TargetPosition string `json:"target_position"`
}
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
}
if args.ResumeText == "" {
return "", fmt.Errorf("resume_text is required")
}
result := `{"score": 85.5, "strengths": ["项目经验丰富,技术栈匹配度高", "工作经历描述清晰", "有量化成果展示"], "weaknesses": ["个人简介不够突出", "缺少核心优势总结", "部分技术栈描述不够详细"], "suggestions": ["优化个人简介,突出核心竞争力", "增加核心优势板块", "补充技术细节描述"], "keyword_gaps": ["微服务", "云原生", "分布式系统"]}`
return result, nil
}
type KeywordExtractTool struct{}
func NewKeywordExtractTool() *KeywordExtractTool {
return &KeywordExtractTool{}
}
type GenerateQuestionsTool struct{}
func NewGenerateQuestionsTool() *GenerateQuestionsTool {
return &GenerateQuestionsTool{}
}
func (t *GenerateQuestionsTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "generate_interview_questions",
Desc: "根据简历内容生成面试题目,包括选择题、填空题、简答题和算法设计题。输入简历文本,返回结构化的题目列表,每类5题,按类型排序。",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"resume_text": {
Desc: "简历文本内容(JSON格式)",
Required: true,
Type: schema.String,
},
"keywords": {
Desc: "指定关键词,逗号分隔,如:Go,MySQL,微服务",
Type: schema.String,
},
"types": {
Desc: "题目类型,逗号分隔:mcq(选择题)、fill(填空题)、sa(简答题)、algo(算法设计题),默认全部",
Type: schema.String,
},
}),
}, nil
}
func (t *GenerateQuestionsTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
var args struct {
ResumeText string `json:"resume_text"`
Keywords string `json:"keywords"`
Types string `json:"types"`
Seed string `json:"seed"`
Random bool `json:"random"`
}
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
}
if args.ResumeText == "" && args.Keywords == "" {
return "", fmt.Errorf("resume_text or keywords is required")
}
if args.ResumeText == "" {
args.ResumeText = "基于关键词:" + args.Keywords
}
questions := generateMockQuestions(args.ResumeText, args.Keywords, args.Types, args.Seed, args.Random)
resultJSON, err := json.Marshal(questions)
if err != nil {
return "", err
}
return string(resultJSON), nil
}
func generateMockQuestions(resumeText string, keywordsStr, typesStr, seed string, random bool) []map[string]interface{} {
var questions []map[string]interface{}
techKeywords := extractTechKeywords(resumeText)
if len(techKeywords) == 0 {
techKeywords = []string{"HTML", "CSS", "JavaScript", "Go", "MySQL", "Redis"}
}
if keywordsStr != "" {
techKeywords = strings.Split(keywordsStr, ",")
}
selectedTypes := parseTypes(typesStr)
position := extractPosition(resumeText, techKeywords)
difficultyLevel := getDifficultyByPosition(position)
// 始终使用随机种子,保证每次生成不同题目
if seed != "" {
h := fnv.New32a()
h.Write([]byte(seed))
rand.Seed(int64(h.Sum32()))
} else {
rand.Seed(time.Now().UnixNano())
}
mcqQuestions := []questionItem{
{"Go语言中,以下哪个关键字用于声明常量?", []string{"var", "const", "static", "final"}, "B",
"**解析:** const是Go语言中用于声明常量的关键字。常量是在编译时确定的值,不可修改。", []string{"Go", "常量"}, "入门", "Go基础"},
{"以下哪个不是Go语言的基本类型?", []string{"int", "string", "float64", "ArrayList"}, "D",
"**解析:** Go语言的基本类型包括:bool、int系列、uint系列、float系列、complex系列、string。", []string{"Go", "类型"}, "入门", "Go基础"},
{"Go语言中,make函数不能用于创建哪种类型?", []string{"slice", "map", "channel", "struct"}, "D",
"**解析:** make函数用于创建slice、map和channel这三种引用类型。", []string{"Go", "make"}, "初级", "Go基础"},
{"以下哪个方法可以安全地并发访问map?", []string{"直接读写", "使用sync.Mutex", "使用atomic包", "无需任何措施"}, "B",
"**解析:** Go语言的map不是并发安全的,需要使用sync.Mutex进行保护。", []string{"Go", "并发", "map"}, "中级", "Go并发"},
{"Go语言中,interface{}表示什么?", []string{"空接口", "无类型", "错误类型", "指针类型"}, "A",
"**解析:** interface{}是空接口,任何类型都实现了空接口。", []string{"Go", "接口"}, "初级", "Go基础"},
{"Go语言中,defer语句的执行顺序是?", []string{"先进先出", "先进后出", "按顺序执行", "随机执行"}, "B",
"**解析:** defer语句采用栈式管理,遵循先进后出(LIFO)原则。", []string{"Go", "defer"}, "初级", "Go基础"},
{"以下哪种情况会导致goroutine泄漏?", []string{"channel发送后关闭", "channel接收但无发送者", "使用sync.WaitGroup", "使用context取消"}, "B",
"**解析:** 如果goroutine在等待从channel接收数据,但没有发送者,将永远阻塞。", []string{"Go", "goroutine", "并发"}, "进阶", "Go并发"},
{"Go语言中,GOMAXPROCS的作用是?", []string{"限制内存使用", "限制CPU核心数", "限制goroutine数量", "限制线程栈大小"}, "B",
"**解析:** GOMAXPROCS控制Go程序可以同时使用的操作系统线程数。", []string{"Go", "GOMAXPROCS", "并发"}, "中级", "Go并发"},
{"MySQL中,以下哪个索引类型适合全文搜索?", []string{"B-Tree", "HASH", "FULLTEXT", "SPATIAL"}, "C",
"**解析:** FULLTEXT索引专门用于全文搜索。", []string{"MySQL", "索引"}, "初级", "MySQL"},
{"Redis中,哪个命令可以原子性地设置键值并设置过期时间?", []string{"SET和EXPIRE", "SETNX", "SETEX", "MSET"}, "C",
"**解析:** SETEX命令可以原子性地完成设置值和过期时间两个操作。", []string{"Redis", "命令"}, "初级", "Redis"},
{"HTTP协议中,哪个方法不是幂等的?", []string{"GET", "PUT", "DELETE", "POST"}, "D",
"**解析:** POST方法不是幂等的,多次执行可能产生不同的结果。", []string{"HTTP", "REST"}, "入门", "网络协议"},
{"微服务架构中,服务发现的作用是?", []string{"负载均衡", "服务注册与查找", "数据加密", "日志收集"}, "B",
"**解析:** 服务发现允许服务动态注册和查找其他服务的网络位置。", []string{"微服务", "服务发现"}, "初级", "架构"},
{"Go语言中,以下哪个是值类型?", []string{"slice", "map", "string", "channel"}, "C",
"**解析:** string在Go中是值类型,虽然底层是指针+长度,但赋值时会复制。", []string{"Go", "类型"}, "初级", "Go基础"},
{"Go语言中,channel默认是?", []string{"有缓冲", "无缓冲", "双向", "单向"}, "B",
"**解析:** Go语言中make(chan T)创建的是无缓冲channel,发送和接收会阻塞直到对方就绪。", []string{"Go", "channel"}, "初级", "Go并发"},
{"MySQL中,InnoDB的默认隔离级别是?", []string{"READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"}, "C",
"**解析:** InnoDB默认隔离级别是REPEATABLE READ,可以防止不可重复读。", []string{"MySQL", "事务"}, "中级", "MySQL"},
{"Redis中,哪种数据结构最适合实现排行榜?", []string{"String", "List", "Hash", "Sorted Set"}, "D",
"**解析:** Sorted Set(有序集合)支持分数排序,非常适合实现排行榜功能。", []string{"Redis", "数据结构"}, "初级", "Redis"},
{"Docker中,COPY和ADD命令的主要区别是?", []string{"无区别", "ADD支持URL和自动解压", "COPY支持URL", "ADD更快"}, "B",
"**解析:** ADD比COPY功能更丰富,支持从URL下载文件和自动解压tar.gz。", []string{"Docker", "镜像"}, "初级", "DevOps"},
{"Kubernetes中,Pod的最小运行实例数是?", []string{"0", "1", "2", "3"}, "A",
"**解析:** Pod的replicas可以设置为0,表示不运行任何实例。", []string{"Kubernetes", "Pod"}, "初级", "DevOps"},
{"TCP协议工作在OSI模型的哪一层?", []string{"网络层", "传输层", "应用层", "数据链路层"}, "B",
"**解析:** TCP是传输层协议,负责端到端的可靠数据传输。", []string{"TCP", "网络"}, "入门", "网络协议"},
{"HTTPS默认使用的端口号是?", []string{"80", "8080", "443", "8443"}, "C",
"**解析:** HTTPS默认使用443端口,HTTP默认使用80端口。", []string{"HTTP", "HTTPS"}, "入门", "网络协议"},
{"Git中,git merge和git rebase的主要区别是?", []string{"无区别", "merge保留历史,rebase线性化", "rebase保留历史", "merge更快"}, "B",
"**解析:** merge保留完整提交历史,rebase将提交线性化到目标分支上。", []string{"Git", "版本控制"}, "初级", "工具"},
{"RESTful API中,删除资源应该使用哪个HTTP方法?", []string{"GET", "POST", "PUT", "DELETE"}, "D",
"**解析:** DELETE方法用于删除指定的资源,是幂等操作。", []string{"API", "REST"}, "入门", "架构"},
{"HTML中,哪个标签用于定义无序列表?", []string{"<ol>", "<ul>", "<li>", "<dl>"}, "B",
"**解析:** <ul>标签用于定义无序列表,<ol>用于有序列表,<li>是列表项,<dl>是定义列表。", []string{"HTML", "列表"}, "入门", "HTML"},
{"HTML5中,哪个标签用于定义导航链接?", []string{"<nav>", "<header>", "<section>", "<article>"}, "A",
"**解析:** <nav>标签定义导航链接的部分,用于页面的主要导航区域。", []string{"HTML5", "语义化"}, "入门", "HTML"},
{"CSS中,哪个属性用于设置元素的边框?", []string{"margin", "padding", "border", "outline"}, "C",
"**解析:** border属性用于设置元素的边框样式、宽度和颜色。", []string{"CSS", "边框"}, "入门", "CSS"},
{"CSS中,flex-direction: column的作用是?", []string{"水平排列", "垂直排列", "反向排列", "换行"}, "B",
"**解析:** flex-direction: column使flex容器的子元素垂直排列。", []string{"CSS", "Flexbox"}, "初级", "CSS"},
{"JavaScript中,typeof null的返回值是?", []string{"'null'", "'undefined'", "'object'", "'number'"}, "C",
"**解析:** typeof null返回'object',这是JavaScript的一个历史遗留bug。", []string{"JavaScript", "类型"}, "初级", "JavaScript"},
{"JavaScript中,哪个方法用于阻止事件冒泡?", []string{"preventDefault()", "stopPropagation()", "stopImmediatePropagation()", "cancelBubble()"}, "B",
"**解析:** stopPropagation()方法阻止事件继续传播到父元素。", []string{"JavaScript", "事件"}, "初级", "JavaScript"},
{"CSS中,哪个选择器的优先级最高?", []string{"类选择器", "ID选择器", "标签选择器", "伪类选择器"}, "B",
"**解析:** ID选择器的优先级最高,其次是类选择器和伪类选择器,最后是标签选择器。", []string{"CSS", "选择器"}, "初级", "CSS"},
{"HTML中,哪个属性用于指定表单提交的URL?", []string{"method", "action", "enctype", "target"}, "B",
"**解析:** action属性指定表单提交的目标URL。", []string{"HTML", "表单"}, "入门", "HTML"},
{"JavaScript中,Promise的三种状态不包括?", []string{"pending", "resolved", "rejected", "fulfilled"}, "B",
"**解析:** Promise的三种状态是pending(等待中)、fulfilled(已成功)、rejected(已失败)。resolved不是标准状态。", []string{"JavaScript", "Promise"}, "中级", "JavaScript"},
}
fillQuestions := []questionItem{
{"Go语言中,___关键字用于启动一个goroutine。", nil, "go",
"**解析:** go关键字用于启动goroutine,它会在新的goroutine中执行指定的函数。", []string{"Go", "goroutine"}, "入门", "Go基础"},
{"Go语言的包管理工具是___。", nil, "go mod",
"**解析:** go mod是Go 1.11引入的官方包管理工具,用于管理项目依赖。", []string{"Go", "包管理"}, "入门", "Go基础"},
{"HTTP状态码___表示请求成功。", nil, "200",
"**解析:** HTTP 200 OK表示请求成功。", []string{"HTTP", "状态码"}, "入门", "网络协议"},
{"JSON序列化使用的包是___。", nil, "encoding/json",
"**解析:** encoding/json包提供了JSON序列化和反序列化功能。", []string{"Go", "JSON"}, "初级", "Go基础"},
{"Go语言中,___关键字用于错误处理。", nil, "error",
"**解析:** error是Go语言的错误类型,通常作为函数的返回值返回。", []string{"Go", "错误处理"}, "入门", "Go基础"},
{"MySQL事务的ACID特性中,A代表___。", nil, "Atomicity/原子性",
"**解析:** ACID分别代表:Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。", []string{"MySQL", "事务"}, "初级", "MySQL"},
{"Redis中,___命令用于查看键的剩余过期时间。", nil, "TTL",
"**解析:** TTL命令返回键的剩余生存时间(秒)。", []string{"Redis", "命令"}, "初级", "Redis"},
{"TCP三次握手过程中,第二次握手服务器发送的报文包含SYN和___标志位。", nil, "ACK",
"**解析:** 三次握手:1.客户端发送SYN;2.服务器回复SYN+ACK3.客户端发送ACK。", []string{"TCP", "网络"}, "中级", "网络协议"},
{"Go语言中,___函数用于创建切片、map和channel。", nil, "make",
"**解析:** make函数专门用于创建slice、map和channel这三种引用类型。", []string{"Go", "make"}, "入门", "Go基础"},
{"Go语言中,___函数用于分配内存并返回指针。", nil, "new",
"**解析:** new函数分配内存,返回指向该类型零值的指针。", []string{"Go", "new"}, "入门", "Go基础"},
{"MySQL中,___约束确保列中的值唯一。", nil, "UNIQUE",
"**解析:** UNIQUE约束确保列中的所有值都是不同的。", []string{"MySQL", "约束"}, "初级", "MySQL"},
{"Redis中,___数据结构适合实现消息队列。", nil, "List",
"**解析:** List(列表)支持先进先出(FIFO)操作,适合实现消息队列。", []string{"Redis", "数据结构"}, "初级", "Redis"},
{"Docker中,___命令用于构建镜像。", nil, "docker build",
"**解析:** docker build命令根据Dockerfile构建Docker镜像。", []string{"Docker", "镜像"}, "初级", "DevOps"},
{"Kubernetes中,___用于管理Pod的副本数。", nil, "ReplicaSet",
"**解析:** ReplicaSet确保指定数量的Pod副本在运行。", []string{"Kubernetes", "ReplicaSet"}, "初级", "DevOps"},
{"HTTP状态码___表示服务器内部错误。", nil, "500",
"**解析:** HTTP 500 Internal Server Error表示服务器遇到了意外情况。", []string{"HTTP", "状态码"}, "入门", "网络协议"},
{"Git中,___命令用于创建新分支。", nil, "git branch",
"**解析:** git branch命令用于创建、列出和删除分支。", []string{"Git", "分支"}, "入门", "工具"},
{"HTML中,___标签用于定义表格的表头单元格。", nil, "th",
"**解析:** <th>标签定义表头单元格,通常显示为粗体居中。", []string{"HTML", "表格"}, "入门", "HTML"},
{"CSS中,___属性用于设置元素的背景颜色。", nil, "background-color",
"**解析:** background-color属性设置元素的背景颜色。", []string{"CSS", "背景"}, "入门", "CSS"},
{"JavaScript中,___关键字用于声明常量。", nil, "const",
"**解析:** const声明的常量不能重新赋值。", []string{"JavaScript", "变量"}, "入门", "JavaScript"},
{"HTML5中,___标签用于播放视频。", nil, "video",
"**解析:** <video>标签定义视频播放器。", []string{"HTML5", "媒体"}, "入门", "HTML"},
{"CSS中,___单位是相对于根元素字体大小的。", nil, "rem",
"**解析:** remroot em)是相对于<html>元素的字体大小。", []string{"CSS", "单位"}, "初级", "CSS"},
}
saQuestions := []questionItem{
{"请简述Go语言的并发模型。", nil,
"Go语言采用goroutine和channel实现并发。goroutine是轻量级线程,由Go运行时管理。channel用于goroutine之间的通信,支持同步和异步模式。select语句可以同时等待多个channel操作。",
"**深入解析:**\n\n**1. Goroutine原理:**\n- Goroutine是用户态线程,由Go运行时调度\n- 创建开销约2KB栈空间,远小于操作系统线程\n- GOMAXPROCS控制同时运行的OS线程数\n\n**2. Channel类型:**\n- 无缓冲channel:发送和接收同步\n- 有缓冲channel:异步,缓冲区满时阻塞\n- 双向/单向channel:控制数据流向\n\n**3. 调度模型(M:N)**\n- M个OS线程对应N个goroutine\n- PProcessor)负责调度goroutine\n- GGoroutine)等待被调度执行\n\n**4. 并发同步原语:**\n- sync.Mutex/RWMutex:互斥锁\n- sync.WaitGroup:等待多个goroutine完成\n- sync.Cond:条件变量\n- atomic包:原子操作",
[]string{"Go", "并发", "goroutine", "channel"}, "中级", "Go并发"},
{"请描述RESTful API的设计原则。", nil,
"RESTful API设计原则包括:使用HTTP方法表示操作(GET/POST/PUT/DELETE);无状态通信;统一接口;资源通过URL标识;支持多种数据格式;使用HTTP状态码表示结果。",
"**深入解析:**\n\n**1. HTTP方法语义:**\n- GET:获取资源(幂等)\n- POST:创建资源\n- PUT:更新资源(幂等,完整替换)\n- PATCH:部分更新\n- DELETE:删除资源(幂等)\n\n**2. 无状态原则:**\n- 服务器不存储客户端状态\n- 每次请求包含所有必要信息\n- 便于水平扩展和缓存\n\n**3. 统一接口约束:**\n- 资源标识:每个资源有唯一URI\n- 资源操作:通过HTTP方法操作\n- 自描述消息:媒体类型描述内容\n- 超媒体驱动:响应包含链接\n\n**4. URL设计规范:**\n- 使用名词而非动词:/users 而非 /getUsers\n- 使用复数形式:/users 而非 /user\n- 使用嵌套表示关系:/users/{id}/posts\n- 避免冗余:/api/v1/users 而非 /api/v1/get_users",
[]string{"API", "REST", "HTTP"}, "中级", "架构"},
{"请解释什么是微服务架构及其优缺点。", nil,
"微服务架构是将应用拆分为多个独立的小型服务。优点:独立部署、技术栈灵活、高可用、团队自治。缺点:分布式复杂性、服务间通信开销、数据一致性挑战、运维成本增加。",
"**深入解析:**\n\n**1. 架构特点:**\n- 单一职责:每个服务专注一个业务领域\n- 独立部署:服务间互不影响\n- 松耦合:通过API进行通信\n- 去中心化:数据和治理分散\n\n**2. 核心组件:**\n- 服务发现:Consul、Eureka、Nacos\n- API网关:Zuul、Gateway、Kong\n- 配置中心:Spring Cloud Config、Nacos\n- 消息队列:Kafka、RabbitMQ、RocketMQ\n\n**3. 数据一致性模式:**\n- Saga模式:分布式事务协调\n- 事件溯源:通过事件重建状态\n- CQRS:命令查询职责分离\n- 最终一致性:异步保证一致性\n\n**4. 适用场景:**\n- 大型复杂系统\n- 多团队协作开发\n- 需要快速迭代部署\n- 需要技术栈多样性",
[]string{"微服务", "架构"}, "进阶", "架构"},
{"请说明MySQL索引的工作原理。", nil,
"MySQL索引主要使用B-Tree结构。索引通过排序和分层存储实现快速查找。查询时从根节点开始,通过比较键值向下遍历到叶子节点,找到对应的数据行。索引可以显著提升查询性能,但会降低写入性能。",
"**深入解析:**\n\n**1. B-Tree结构:**\n- 平衡树,所有叶子节点在同一层\n- 每个节点存储多个键值\n- 节点内有序,便于二分查找\n\n**2. B+Tree优化:**\n- 叶子节点存储数据或指向数据的指针\n- 叶子节点形成双向链表\n- 支持范围查询和全表扫描\n\n**3. 索引类型:**\n- 主键索引:唯一且非空\n- 唯一索引:值唯一但可空\n- 普通索引:无限制\n- 联合索引:多列组合\n- 全文索引:文本搜索\n\n**4. 索引失效场景:**\n- 使用!=或NOT IN\n- 使用函数或表达式\n- 使用LIKE '%xxx'\n- 类型不匹配\n- OR条件未全部命中索引\n\n**5. 覆盖索引:**\n- 查询所需字段都在索引中\n- 无需回表查询\n- 显著提升性能",
[]string{"MySQL", "索引", "B-Tree"}, "进阶", "MySQL"},
{"请描述Redis的持久化机制。", nil,
"Redis提供两种持久化方式:RDB(快照)和AOF(追加日志)。RDB定期将内存数据写入磁盘,适合备份和灾难恢复。AOF记录所有写操作,重启时重放恢复数据,数据安全性更高。",
"**深入解析:**\n\n**1. RDB持久化:**\n- 触发方式:定时自动、手动SAVE/BGSAVE\n- 优点:文件体积小、恢复速度快\n- 缺点:可能丢失最近数据\n- 适用场景:备份、灾难恢复\n\n**2. AOF持久化:**\n- 记录所有写命令到日志\n- 同步策略:always/everysec/no\n- 重写机制:压缩日志文件\n- 优点:数据安全,最多丢失1秒数据\n- 缺点:文件体积大、恢复慢\n\n**3. 混合持久化(Redis 4.0+)**\n- RDB作为基础快照\n- AOF记录增量变化\n- 兼顾速度和安全性\n\n**4. 恢复策略:**\n- 优先加载AOF\n- AOF不存在时加载RDB\n- 同时开启时混合加载",
[]string{"Redis", "持久化", "RDB", "AOF"}, "进阶", "Redis"},
{"请说明Go语言中interface的实现原理。", nil,
"Go语言的interface采用鸭子类型,不需要显式声明实现。底层使用iface结构存储类型信息和数据指针。空接口interface{}可以存储任何类型的值。",
"**深入解析:**\n\n**1. iface结构:**\n- tab:指向itab,包含类型信息和方法表\n- data:指向实际数据的指针\n\n**2. itab结构:**\n- inter:接口类型信息\n- _type:实际类型信息\n- fun:方法指针数组\n\n**3. 类型断言:**\n- 安全断言:value, ok := x.(Type)\n- 类型选择:switch v := x.(type) {}\n\n**4. 性能优化:**\n- 避免频繁类型断言\n- 优先使用具体类型而非接口",
[]string{"Go", "接口", "底层原理"}, "高级", "Go基础"},
{"请描述分布式系统中的CAP理论。", nil,
"CAP理论指出分布式系统不可能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)三个特性。在网络分区发生时,必须在一致性和可用性之间做出选择。",
"**深入解析:**\n\n**1. CAP三要素:**\n- C:一致性,所有节点看到相同的数据\n- A:可用性,任何请求都能获得响应\n- P:分区容错性,网络分区时系统继续运行\n\n**2. 权衡选择:**\n- CP:牺牲可用性保证一致性(如ZooKeeper)\n- AP:牺牲一致性保证可用性(如Redis集群)\n\n**3. BASE理论:**\n- 基本可用(Basically Available)\n- 软状态(Soft State)\n- 最终一致性(Eventually Consistent)",
[]string{"分布式", "CAP", "理论"}, "高级", "架构"},
}
algoQuestions := []questionItem{
{"实现一个函数,反转单链表。要求时间复杂度O(n),空间复杂度O(1)。", nil,
"```go\nfunc reverseList(head *ListNode) *ListNode {\n var prev *ListNode\n curr := head\n for curr != nil {\n nextTemp := curr.Next\n curr.Next = prev\n prev = curr\n curr = nextTemp\n }\n return prev\n}\n```",
"**深入解析:**\n\n**1. 算法思路:**\n- 使用三个指针:prev、curr、nextTemp\n- 遍历链表,逐个反转节点指向\n- 时间复杂度:O(n)\n- 空间复杂度:O(1)\n\n**2. 递归解法:**\n```go\nfunc reverseList(head *ListNode) *ListNode {\n if head == nil || head.Next == nil {\n return head\n }\n p := reverseList(head.Next)\n head.Next.Next = head\n head.Next = nil\n return p\n}\n```\n\n**3. 关键点:**\n- 保存下一节点的引用\n- 防止链表断裂\n- 返回新的头节点\n\n**4. 扩展问题:**\n- 反转链表前N个节点\n- 反转链表的一部分\n- K个一组反转链表",
[]string{"算法", "链表", "反转"}, "初级", "算法"},
{"实现一个函数,判断两个字符串是否是异位词。", nil,
"```go\nfunc isAnagram(s, t string) bool {\n if len(s) != len(t) {\n return false\n }\n count := make([]int, 26)\n for i := 0; i < len(s); i++ {\n count[s[i]-'a']++\n count[t[i]-'a']--\n }\n for _, c := range count {\n if c != 0 {\n return false\n }\n }\n return true\n}\n```",
"**深入解析:**\n\n**1. 算法思路:**\n- 统计字符频率\n- 使用数组或哈希表\n- 比较两个字符串的频率\n\n**2. 复杂度分析:**\n- 时间:O(n)\n- 空间:O(1)(固定大小数组)\n\n**3. 扩展情况:**\n- 包含Unicode字符:使用map[rune]int\n- 大小写敏感/不敏感处理\n- 包含空格或特殊字符\n\n**4. 其他解法:**\n- 排序后比较:O(n log n)时间,O(1)空间\n- 使用哈希表:通用但空间稍大\n\n**5. 相关问题:**\n- 找到字符串中第一个不重复的字符\n- 最长无重复子串\n- 字符串排列",
[]string{"算法", "字符串", "哈希表"}, "入门", "算法"},
{"实现快速排序算法。要求平均时间复杂度O(n log n)。", nil,
"```go\nfunc quickSort(nums []int) []int {\n if len(nums) <= 1 {\n return nums\n }\n pivot := nums[len(nums)/2]\n var left, right, middle []int\n for _, num := range nums {\n if num < pivot {\n left = append(left, num)\n } else if num > pivot {\n right = append(right, num)\n } else {\n middle = append(middle, num)\n }\n }\n return append(append(quickSort(left), middle...), quickSort(right)...)\n}\n```",
"**深入解析:**\n\n**1. 算法思想:**\n- 分治策略\n- 选择基准元素\n- 分区:小于/等于/大于基准\n- 递归处理左右分区\n\n**2. 复杂度分析:**\n- 平均时间:O(n log n)\n- 最坏时间:O(n²)(已排序数组)\n- 空间复杂度:O(log n)(递归栈)\n\n**3. 优化策略:**\n- 随机选择基准\n- 三数取中选择基准\n- 小数组使用插入排序\n- 尾递归优化\n\n**4. 原地排序实现:**\n```go\nfunc quickSortInPlace(nums []int, low, high int) {\n if low < high {\n pivot := partition(nums, low, high)\n quickSortInPlace(nums, low, pivot-1)\n quickSortInPlace(nums, pivot+1, high)\n }\n}\n```",
[]string{"算法", "排序", "快速排序"}, "中级", "算法"},
{"实现一个LRU缓存。要求get和put操作时间复杂度O(1)。", nil,
"```go\ntype LRUCache struct {\n capacity int\n cache map[int]*Node\n head *Node\n tail *Node\n}\n\ntype Node struct {\n key, val int\n prev, next *Node\n}\n\nfunc (c *LRUCache) Get(key int) int {\n if node, ok := c.cache[key]; ok {\n c.remove(node)\n c.addToFront(node)\n return node.val\n }\n return -1\n}\n\nfunc (c *LRUCache) Put(key, value int) {\n if node, ok := c.cache[key]; ok {\n node.val = value\n c.remove(node)\n c.addToFront(node)\n return\n }\n if len(c.cache) == c.capacity {\n delete(c.cache, c.tail.key)\n c.remove(c.tail)\n }\n newNode := &Node{key: key, val: value}\n c.cache[key] = newNode\n c.addToFront(newNode)\n}\n```",
"**深入解析:**\n\n**1. 数据结构:**\n- 哈希表:O(1)查找\n- 双向链表:O(1)删除和插入\n- 链表头部:最近使用\n- 链表尾部:最久未使用\n\n**2. 核心操作:**\n- Get:查找并移到头部\n- Put:插入头部,满时删除尾部\n- Remove:从链表中移除\n- AddToFront:添加到头部\n\n**3. 复杂度分析:**\n- GetO(1)\n- PutO(1)\n- 空间:O(capacity)\n\n**4. 扩展:LFU缓存**\n- 最少使用频率淘汰\n- 需要维护频率计数\n- 同频率按LRU淘汰",
[]string{"算法", "缓存", "LRU"}, "进阶", "算法"},
{"实现二叉树的层序遍历。", nil,
"```go\nfunc levelOrder(root *TreeNode) [][]int {\n var result [][]int\n if root == nil {\n return result\n }\n queue := []*TreeNode{root}\n for len(queue) > 0 {\n levelSize := len(queue)\n var currentLevel []int\n for i := 0; i < levelSize; i++ {\n node := queue[0]\n queue = queue[1:]\n currentLevel = append(currentLevel, node.Val)\n if node.Left != nil {\n queue = append(queue, node.Left)\n }\n if node.Right != nil {\n queue = append(queue, node.Right)\n }\n }\n result = append(result, currentLevel)\n }\n return result\n}\n```",
"**深入解析:**\n\n**1. 算法思路:**\n- 使用队列进行广度优先遍历\n- 按层处理节点\n- 记录每层节点数\n- 逐层收集结果\n\n**2. 复杂度分析:**\n- 时间:O(n),每个节点访问一次\n- 空间:O(n),最坏情况队列存储所有节点\n\n**3. 变体问题:**\n- 锯齿形层序遍历\n- 层序遍历从底向上\n- 每层最大值\n- 二叉树的右视图\n\n**4. 递归实现:**\n```go\nfunc levelOrderRecursive(root *TreeNode) [][]int {\n var result [][]int\n dfs(root, 0, &result)\n return result\n}\n```",
[]string{"算法", "二叉树", "层序遍历"}, "初级", "算法"},
{"实现二分查找算法。", nil,
"```go\nfunc binarySearch(nums []int, target int) int {\n left, right := 0, len(nums)-1\n for left <= right {\n mid := left + (right-left)/2\n if nums[mid] == target {\n return mid\n } else if nums[mid] < target {\n left = mid + 1\n } else {\n right = mid - 1\n }\n }\n return -1\n}\n```",
"**深入解析:**\n\n**1. 算法思想:**\n- 分治策略\n- 有序数组前提\n- 每次排除一半元素\n\n**2. 复杂度分析:**\n- 时间:O(log n)\n- 空间:O(1)\n\n**3. 边界处理:**\n- 避免整数溢出:mid = left + (right-left)/2\n- 循环条件:left <= right\n\n**4. 变体问题:**\n- 查找第一个等于target的元素\n- 查找最后一个等于target的元素\n- 查找第一个大于等于target的元素",
[]string{"算法", "二分查找"}, "入门", "算法"},
{"实现合并两个有序链表。", nil,
"```go\nfunc mergeTwoLists(l1, l2 *ListNode) *ListNode {\n dummy := &ListNode{}\n curr := dummy\n for l1 != nil && l2 != nil {\n if l1.Val < l2.Val {\n curr.Next = l1\n l1 = l1.Next\n } else {\n curr.Next = l2\n l2 = l2.Next\n }\n curr = curr.Next\n }\n if l1 != nil {\n curr.Next = l1\n } else {\n curr.Next = l2\n }\n return dummy.Next\n}\n```",
"**深入解析:**\n\n**1. 算法思路:**\n- 虚拟头节点简化边界处理\n- 双指针遍历两个链表\n- 比较节点值,选择较小者\n\n**2. 复杂度分析:**\n- 时间:O(n+m)\n- 空间:O(1)\n\n**3. 递归解法:**\n```go\nfunc mergeTwoLists(l1, l2 *ListNode) *ListNode {\n if l1 == nil { return l2 }\n if l2 == nil { return l1 }\n if l1.Val < l2.Val {\n l1.Next = mergeTwoLists(l1.Next, l2)\n return l1\n }\n l2.Next = mergeTwoLists(l1, l2.Next)\n return l2\n}\n```",
[]string{"算法", "链表", "合并"}, "初级", "算法"},
}
shuffleSlice := func(slice interface{}) {
switch s := slice.(type) {
case []questionItem:
for i := len(s) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
s[i], s[j] = s[j], s[i]
}
}
}
if containsType(selectedTypes, "mcq") {
mcqFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(mcqQuestions, techKeywords), difficultyLevel)
shuffleSlice(mcqFiltered)
for i := 0; i < 5 && i < len(mcqFiltered); i++ {
mcq := mcqFiltered[i]
questions = append(questions, map[string]interface{}{
"type": "mcq",
"text": mcq.question,
"options": mcq.options,
"answer": mcq.answer,
"analysis": mcq.analysis,
"score": getScoreByDifficulty(mcq.difficulty),
"keywords": mcq.keywords,
"difficulty": mcq.difficulty,
"category": mcq.category,
})
}
}
if containsType(selectedTypes, "fill") {
fillFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(fillQuestions, techKeywords), difficultyLevel)
shuffleSlice(fillFiltered)
for i := 0; i < 5 && i < len(fillFiltered); i++ {
fill := fillFiltered[i]
questions = append(questions, map[string]interface{}{
"type": "fill",
"text": fill.question,
"answer": fill.answer,
"analysis": fill.analysis,
"score": getScoreByDifficulty(fill.difficulty),
"keywords": fill.keywords,
"difficulty": fill.difficulty,
"category": fill.category,
})
}
}
if containsType(selectedTypes, "sa") {
saFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(saQuestions, techKeywords), difficultyLevel)
shuffleSlice(saFiltered)
for i := 0; i < 5 && i < len(saFiltered); i++ {
sa := saFiltered[i]
questions = append(questions, map[string]interface{}{
"type": "sa",
"text": sa.question,
"answer": sa.answer,
"analysis": sa.analysis,
"score": getScoreByDifficulty(sa.difficulty),
"keywords": sa.keywords,
"difficulty": sa.difficulty,
"category": sa.category,
})
}
}
if containsType(selectedTypes, "algo") {
algoFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(algoQuestions, techKeywords), difficultyLevel)
shuffleSlice(algoFiltered)
for i := 0; i < 5 && i < len(algoFiltered); i++ {
algo := algoFiltered[i]
questions = append(questions, map[string]interface{}{
"type": "algo",
"text": algo.question,
"answer": algo.answer,
"analysis": algo.analysis,
"score": getScoreByDifficulty(algo.difficulty),
"keywords": algo.keywords,
"difficulty": algo.difficulty,
"category": algo.category,
})
}
}
return questions
}
func getScoreByDifficulty(difficulty string) int {
switch difficulty {
case "入门":
return 5
case "初级":
return 10
case "中级":
return 15
case "进阶":
return 20
case "高级":
return 25
default:
return 10
}
}
func parseTypes(typesStr string) []string {
if typesStr == "" {
return []string{"mcq", "fill", "sa", "algo"}
}
return strings.Split(typesStr, ",")
}
func containsType(types []string, t string) bool {
for _, tp := range types {
if tp == t {
return true
}
}
return false
}
type questionItem struct {
question string
options []string
answer string
analysis string
keywords []string
difficulty string
category string
}
func extractPosition(resumeText string, keywords []string) string {
positionKeywords := map[string][]string{
"高级工程师": {"高级", "资深", "architect", "架构师", "技术负责人", "tech lead", "lead"},
"中级工程师": {"中级", "工程师", "developer", "software engineer", "全栈"},
"初级工程师": {"初级", "助理", "实习生", "junior", "graduate", "entry"},
}
resumeLower := strings.ToLower(resumeText)
for position, keywords := range positionKeywords {
for _, kw := range keywords {
if strings.Contains(resumeLower, strings.ToLower(kw)) {
return position
}
}
}
for _, kw := range keywords {
if strings.Contains(strings.ToLower(kw), "架构") || strings.Contains(strings.ToLower(kw), "架构师") {
return "高级工程师"
}
}
return "中级工程师"
}
func getDifficultyByPosition(position string) string {
switch position {
case "高级工程师":
return "高级"
case "中级工程师":
return "中级"
case "初级工程师":
return "初级"
default:
return "中级"
}
}
func filterQuestionsByDifficulty(questions []questionItem, minDifficulty string) []questionItem {
difficultyOrder := map[string]int{
"入门": 0,
"初级": 1,
"中级": 2,
"进阶": 3,
"高级": 4,
}
minLevel := difficultyOrder[minDifficulty]
var filtered []questionItem
for _, q := range questions {
if difficultyOrder[q.difficulty] >= minLevel {
filtered = append(filtered, q)
}
}
if len(filtered) == 0 {
return questions
}
return filtered
}
// keywordAliases 关键词别名映射表,扩展用户输入的关键词以提高匹配命中率
// 例如:用户输入 "React" 时,同时匹配 "前端"、"JavaScript" 等相关关键词
var keywordAliases = map[string][]string{
"go": {"go", "golang", "goroutine", "channel", "defer", "make", "const", "var", "interface", "gomaxprocs", "panic", "recover", "new"},
"golang": {"go", "golang", "goroutine", "channel", "defer", "make", "interface"},
"java": {"java", "jvm", "spring", "maven", "gradle", "jdk", "jre"},
"python": {"python", "django", "flask", "pandas", "numpy"},
"javascript": {"javascript", "js", "es6", "promise", "async", "await"},
"typescript": {"typescript", "ts", "javascript", "js"},
"react": {"react", "jsx", "tsx", "hooks", "redux", "前端", "javascript"},
"vue": {"vue", "vuex", "vuejs", "前端", "javascript"},
"mysql": {"mysql", "sql", "数据库", "innodb", "事务", "索引", "acid"},
"redis": {"redis", "缓存", "rdb", "aof", "sorted set", "ttl"},
"docker": {"docker", "容器", "镜像", "dockerfile"},
"kubernetes": {"kubernetes", "k8s", "pod", "replicaset", "container"},
"k8s": {"kubernetes", "k8s", "pod", "replicaset"},
"微服务": {"微服务", "架构", "rest", "api", "服务发现", "分布式"},
"分布式": {"分布式", "cap", "微服务", "一致性", "raft", "paxos"},
"网络": {"网络", "tcp", "http", "https", "tcp/ip", "osi"},
"http": {"http", "https", "rest", "api", "状态码"},
"https": {"https", "http", "ssl", "tls"},
"算法": {"算法", "链表", "二叉树", "排序", "查找", "二分查找", "快速排序", "lru"},
"html": {"html", "html5", "前端", "表单"},
"html5": {"html5", "html", "前端"},
"css": {"css", "flexbox", "rem", "选择器", "前端"},
"git": {"git", "版本控制", "分支"},
"api": {"api", "rest", "restful", "接口"},
"rest": {"rest", "restful", "api", "http"},
"tcp": {"tcp", "网络", "osi", "三次握手"},
}
// expandKeywords 扩展关键词列表,加入别名
func expandKeywords(keywords []string) []string {
seen := make(map[string]bool)
var expanded []string
for _, kw := range keywords {
kwLower := strings.ToLower(strings.TrimSpace(kw))
if kwLower == "" {
continue
}
if !seen[kwLower] {
seen[kwLower] = true
expanded = append(expanded, kwLower)
}
if aliases, ok := keywordAliases[kwLower]; ok {
for _, alias := range aliases {
if !seen[alias] {
seen[alias] = true
expanded = append(expanded, alias)
}
}
}
}
return expanded
}
func filterQuestionsByKeywords(questions []questionItem, keywords []string) []questionItem {
if len(keywords) == 0 {
return questions
}
expandedKeywords := expandKeywords(keywords)
if len(expandedKeywords) == 0 {
return questions
}
var filtered []questionItem
for _, q := range questions {
matched := false
for _, qkw := range q.keywords {
qkwLower := strings.ToLower(qkw)
for _, kw := range expandedKeywords {
if qkwLower == kw || strings.Contains(qkwLower, kw) || strings.Contains(kw, qkwLower) {
filtered = append(filtered, q)
matched = true
break
}
}
if matched {
break
}
}
}
// 严格过滤:未匹配时返回空切片,由调用方处理(GenerateQuestions 会返回"未找到与关键词匹配的题目")
return filtered
}
func (t *KeywordExtractTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "extract_keywords",
Desc: "从简历文本中提取关键词,包括技术栈、技能、行业术语等。输入简历文本,返回结构化的关键词列表。",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"text": {
Desc: "简历文本内容",
Required: true,
Type: schema.String,
},
"category": {
Desc: "提取类别:all(全部)、tech(技术栈)、skill(技能)、industry(行业术语)、soft(软技能)",
Type: schema.String,
Enum: []string{"all", "tech", "skill", "industry", "soft"},
},
}),
}, nil
}
func (t *KeywordExtractTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
var args struct {
Text string `json:"text"`
Category string `json:"category"`
}
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
}
if args.Text == "" {
return "", fmt.Errorf("text is required")
}
if args.Category == "" {
args.Category = "all"
}
techStack := extractTechKeywords(args.Text)
skills := extractSkillKeywords(args.Text)
industryTerms := extractIndustryKeywords(args.Text)
softSkills := extractSoftSkillKeywords(args.Text)
result := fmt.Sprintf(`{"tech_stack": %s, "skills": %s, "industry_terms": %s, "soft_skills": %s}`,
toJSONArray(techStack),
toJSONArray(skills),
toJSONArray(industryTerms),
toJSONArray(softSkills),
)
return result, nil
}
func toJSONArray(items []string) string {
if len(items) == 0 {
return "[]"
}
result := "["
for i, item := range items {
if i > 0 {
result += ","
}
result += fmt.Sprintf(`"%s"`, escapeJSON(item))
}
result += "]"
return result
}
func extractTechKeywords(text string) []string {
techPatterns := []string{"Go", "Golang", "Java", "Python", "JavaScript", "TypeScript", "React", "Vue", "MySQL", "PostgreSQL", "Redis", "MongoDB", "Kafka", "Docker", "Kubernetes", "微服务", "分布式"}
return extractKeywords(text, techPatterns)
}
func extractSkillKeywords(text string) []string {
skillPatterns := []string{"开发", "设计", "架构", "优化", "重构", "测试", "部署", "运维", "监控", "调优"}
return extractKeywords(text, skillPatterns)
}
func extractIndustryKeywords(text string) []string {
industryPatterns := []string{"互联网", "金融", "电商", "医疗", "教育", "云计算", "大数据", "人工智能", "物联网"}
return extractKeywords(text, industryPatterns)
}
func extractSoftSkillKeywords(text string) []string {
softPatterns := []string{"沟通", "团队协作", "项目管理", "问题解决", "创新", "学习能力", "抗压能力", "责任心"}
return extractKeywords(text, softPatterns)
}
func extractKeywords(text string, patterns []string) []string {
var result []string
for _, pattern := range patterns {
if strings.Contains(text, pattern) {
result = append(result, pattern)
}
}
return result
}
+113
View File
@@ -0,0 +1,113 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"resume-platform/pkg/config"
"time"
)
// DeepSeekProvider DeepSeek AI 供应商实现
type DeepSeekProvider struct {
cfg *config.DeepSeekConfig // DeepSeek 配置
httpClient *http.Client // HTTP 客户端
}
// NewDeepSeekProvider 创建 DeepSeek 供应商实例
// @param cfg DeepSeek 配置
// @return *DeepSeekProvider 供应商实例
// @author sunct
func NewDeepSeekProvider(cfg *config.DeepSeekConfig) *DeepSeekProvider {
return &DeepSeekProvider{
cfg: cfg,
httpClient: &http.Client{
Timeout: time.Duration(cfg.Timeout) * time.Second,
},
}
}
// GetName 获取供应商名称
// @return string 供应商标识
// @author sunct
func (p *DeepSeekProvider) GetName() string {
return "deepseek"
}
// Generate 调用 DeepSeek API 生成文本
// @param ctx 上下文
// @param prompt 提示词
// @param opts 可选参数
// @return string 生成结果
// @return error 调用错误
// @author sunct
func (p *DeepSeekProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
options := &Options{
MaxTokens: p.cfg.MaxTokens,
Temperature: 0.7,
TopP: 0.9,
}
for _, opt := range opts {
opt(options)
}
body := map[string]interface{}{
"model": p.getModelName(),
"messages": []map[string]interface{}{
{
"role": "user",
"content": prompt,
},
},
"max_tokens": options.MaxTokens,
"temperature": options.Temperature,
"top_p": options.TopP,
}
jsonBody, _ := json.Marshal(body)
req, err := http.NewRequest("POST", p.cfg.BaseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return string(respBody), nil
}
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
if choice, ok := choices[0].(map[string]interface{}); ok {
if message, ok := choice["message"].(map[string]interface{}); ok {
if content, ok := message["content"].(string); ok {
return content, nil
}
}
}
}
return string(respBody), nil
}
// getModelName 获取模型名称,未配置时使用默认 deepseek-chat
// @return string 模型名
// @author sunct
func (p *DeepSeekProvider) getModelName() string {
if p.cfg.Model != "" {
return p.cfg.Model
}
return "deepseek-chat"
}
+113
View File
@@ -0,0 +1,113 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"resume-platform/pkg/config"
"time"
)
// DoubaoProvider 豆包 AI 供应商实现
type DoubaoProvider struct {
cfg *config.DoubaoConfig // 豆包配置
httpClient *http.Client // HTTP 客户端
}
// NewDoubaoProvider 创建豆包供应商实例
// @param cfg 豆包配置
// @return *DoubaoProvider 供应商实例
// @author sunct
func NewDoubaoProvider(cfg *config.DoubaoConfig) *DoubaoProvider {
return &DoubaoProvider{
cfg: cfg,
httpClient: &http.Client{
Timeout: time.Duration(cfg.Timeout) * time.Second,
},
}
}
// GetName 获取供应商名称
// @return string 供应商标识
// @author sunct
func (p *DoubaoProvider) GetName() string {
return "doubao"
}
// Generate 调用豆包 API 生成文本
// @param ctx 上下文
// @param prompt 提示词
// @param opts 可选参数
// @return string 生成结果
// @return error 调用错误
// @author sunct
func (p *DoubaoProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
options := &Options{
MaxTokens: p.cfg.MaxTokens,
Temperature: 0.7,
TopP: 0.9,
}
for _, opt := range opts {
opt(options)
}
body := map[string]interface{}{
"model": p.getModelName(),
"messages": []map[string]interface{}{
{
"role": "user",
"content": prompt,
},
},
"max_tokens": options.MaxTokens,
"temperature": options.Temperature,
"top_p": options.TopP,
}
jsonBody, _ := json.Marshal(body)
req, err := http.NewRequest("POST", p.cfg.BaseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return string(respBody), nil
}
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
if choice, ok := choices[0].(map[string]interface{}); ok {
if message, ok := choice["message"].(map[string]interface{}); ok {
if content, ok := message["content"].(string); ok {
return content, nil
}
}
}
}
return string(respBody), nil
}
// getModelName 获取模型名称,未配置时使用默认 Doubao
// @return string 模型名
// @author sunct
func (p *DoubaoProvider) getModelName() string {
if p.cfg.Model != "" {
return p.cfg.Model
}
return "Doubao"
}
+76
View File
@@ -0,0 +1,76 @@
// Package ai AI 供应商适配层,定义统一的 LLM 调用接口
// 支持通义千问、DeepSeek、豆包、讯飞星火等多家大模型
package ai
import (
"context"
"resume-platform/pkg/config"
)
// Provider AI 服务提供者接口,所有大模型供应商需实现此接口
type Provider interface {
// Generate 调用大模型生成文本
Generate(ctx context.Context, prompt string, opts ...Option) (string, error)
// GetName 获取供应商名称
GetName() string
}
// Option 调用选项函数类型,用于可选参数配置
type Option func(*Options)
// Options AI 调用可选参数
type Options struct {
MaxTokens int // 最大生成token数
Temperature float64 // 温度(随机性)
TopP float64 // 核采样参数
}
// WithMaxTokens 设置最大生成 token 数
// @param maxTokens 最大token数
// @return Option 选项函数
// @author sunct
func WithMaxTokens(maxTokens int) Option {
return func(o *Options) {
o.MaxTokens = maxTokens
}
}
// WithTemperature 设置生成温度
// @param temp 温度值(0~1
// @return Option 选项函数
// @author sunct
func WithTemperature(temp float64) Option {
return func(o *Options) {
o.Temperature = temp
}
}
// WithTopP 设置核采样参数
// @param topP 核采样值
// @return Option 选项函数
// @author sunct
func WithTopP(topP float64) Option {
return func(o *Options) {
o.TopP = topP
}
}
// NewProvider AI 供应商工厂函数,根据配置创建对应供应商实例
// @param cfg AI 配置
// @return Provider 供应商实例
// @return error 创建错误
// @author sunct
func NewProvider(cfg *config.AIConfig) (Provider, error) {
switch cfg.Provider {
case "spark":
return NewSparkProvider(&cfg.Spark), nil
case "tongyi":
return NewTongyiProvider(&cfg.Tongyi), nil
case "doubao":
return NewDoubaoProvider(&cfg.Doubao), nil
case "deepseek":
return NewDeepSeekProvider(&cfg.DeepSeek), nil
default:
return NewSparkProvider(&cfg.Spark), nil
}
}
+213
View File
@@ -0,0 +1,213 @@
package ai
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"resume-platform/pkg/config"
"strings"
"time"
"github.com/gorilla/websocket"
)
type SparkProvider struct {
cfg *config.SparkConfig
httpClient *http.Client
}
func NewSparkProvider(cfg *config.SparkConfig) *SparkProvider {
return &SparkProvider{
cfg: cfg,
httpClient: &http.Client{
Timeout: time.Duration(cfg.Timeout) * time.Second,
},
}
}
func (p *SparkProvider) GetName() string {
return "spark"
}
func (p *SparkProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
options := &Options{
MaxTokens: p.cfg.MaxTokens,
Temperature: 0.7,
TopP: 0.9,
}
for _, opt := range opts {
opt(options)
}
wsURL, err := p.buildAuthURL()
if err != nil {
return "", err
}
return p.sendWebSocketRequest(ctx, wsURL, prompt, options)
}
func (p *SparkProvider) buildAuthURL() (string, error) {
parsedURL, err := url.Parse(p.cfg.BaseURL)
if err != nil {
return "", err
}
host := parsedURL.Host
path := parsedURL.Path
now := time.Now()
date := now.UTC().Format(time.RFC1123)
signatureOrigin := fmt.Sprintf("host: %s\ndate: %s\nGET %s HTTP/1.1", host, date, path)
mac := hmac.New(sha256.New, []byte(p.cfg.APISecret))
mac.Write([]byte(signatureOrigin))
signatureSha := base64.StdEncoding.EncodeToString(mac.Sum(nil))
authorizationOrigin := fmt.Sprintf(
`api_key="%s", algorithm="%s", headers="%s", signature="%s"`,
p.cfg.APIKey, "hmac-sha256", "host date request-line", signatureSha,
)
authorization := base64.StdEncoding.EncodeToString([]byte(authorizationOrigin))
query := url.Values{}
query.Set("authorization", authorization)
query.Set("date", date)
query.Set("host", host)
return p.cfg.BaseURL + "?" + query.Encode(), nil
}
func (p *SparkProvider) sendWebSocketRequest(ctx context.Context, wsURL string, prompt string, options *Options) (string, error) {
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return "", fmt.Errorf("failed to dial websocket: %w", err)
}
defer conn.Close()
done := make(chan struct{})
var result strings.Builder
var lastErr error
go func() {
defer close(done)
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return
}
lastErr = fmt.Errorf("failed to read message: %w", err)
return
}
var resp map[string]interface{}
if err := json.Unmarshal(message, &resp); err != nil {
continue
}
if header, ok := resp["header"].(map[string]interface{}); ok {
if code, ok := header["code"].(float64); ok && code != 0 {
if msg, ok := header["message"].(string); ok {
lastErr = fmt.Errorf("API error: %s (code: %d)", msg, int(code))
} else {
lastErr = fmt.Errorf("API error with code: %d", int(code))
}
return
}
}
if payload, ok := resp["payload"].(map[string]interface{}); ok {
if choices, ok := payload["choices"].(map[string]interface{}); ok {
if text, ok := choices["text"].([]interface{}); ok && len(text) > 0 {
if item, ok := text[0].(map[string]interface{}); ok {
if content, ok := item["content"].(string); ok {
result.WriteString(content)
}
}
}
if status, ok := choices["status"].(float64); ok && status == 2 {
return
}
}
}
}
}()
body := map[string]interface{}{
"header": map[string]interface{}{
"app_id": p.cfg.APPID,
"uid": "resume-platform",
},
"parameter": map[string]interface{}{
"chat": map[string]interface{}{
"domain": p.getDomainName(),
"temperature": options.Temperature,
"max_tokens": options.MaxTokens,
"top_k": 4,
"web_search": p.cfg.WebSearch,
"show_ref_label": p.cfg.ShowRefLabel,
},
},
"payload": map[string]interface{}{
"message": map[string]interface{}{
"text": []map[string]interface{}{
{
"role": "user",
"content": prompt,
},
},
},
},
}
jsonBody, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
if err := conn.WriteMessage(websocket.TextMessage, jsonBody); err != nil {
return "", fmt.Errorf("failed to write message: %w", err)
}
select {
case <-done:
if lastErr != nil {
return "", lastErr
}
return result.String(), nil
case <-ctx.Done():
conn.Close()
return "", ctx.Err()
case <-time.After(time.Duration(p.cfg.Timeout) * time.Second):
conn.Close()
return "", fmt.Errorf("request timeout")
}
}
func (p *SparkProvider) getDomainName() string {
switch p.cfg.Model {
case "lite":
return "lite"
case "pro":
return "generalv3"
case "pro-128k":
return "pro-128k"
case "max":
return "generalv3.5"
case "max-32k":
return "max-32k"
case "ultra":
return "4.0Ultra"
case "kjwx":
return "kjwx"
default:
return "lite"
}
}
+113
View File
@@ -0,0 +1,113 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"resume-platform/pkg/config"
"time"
)
// TongyiProvider 通义千问 AI 供应商实现
type TongyiProvider struct {
cfg *config.TongyiConfig // 通义千问配置
httpClient *http.Client // HTTP 客户端
}
// NewTongyiProvider 创建通义千问供应商实例
// @param cfg 通义千问配置
// @return *TongyiProvider 供应商实例
// @author sunct
func NewTongyiProvider(cfg *config.TongyiConfig) *TongyiProvider {
return &TongyiProvider{
cfg: cfg,
httpClient: &http.Client{
Timeout: time.Duration(cfg.Timeout) * time.Second,
},
}
}
// GetName 获取供应商名称
// @return string 供应商标识
// @author sunct
func (p *TongyiProvider) GetName() string {
return "tongyi"
}
// Generate 调用通义千问 API 生成文本
// @param ctx 上下文
// @param prompt 提示词
// @param opts 可选参数
// @return string 生成结果
// @return error 调用错误
// @author sunct
func (p *TongyiProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
options := &Options{
MaxTokens: p.cfg.MaxTokens,
Temperature: 0.7,
TopP: 0.9,
}
for _, opt := range opts {
opt(options)
}
body := map[string]interface{}{
"model": p.getModelName(),
"messages": []map[string]interface{}{
{
"role": "user",
"content": prompt,
},
},
"max_tokens": options.MaxTokens,
"temperature": options.Temperature,
"top_p": options.TopP,
}
jsonBody, _ := json.Marshal(body)
req, err := http.NewRequest("POST", p.cfg.BaseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return string(respBody), nil
}
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
if choice, ok := choices[0].(map[string]interface{}); ok {
if message, ok := choice["message"].(map[string]interface{}); ok {
if content, ok := message["content"].(string); ok {
return content, nil
}
}
}
}
return string(respBody), nil
}
// getModelName 获取模型名称,未配置时使用默认 qwen-turbo
// @return string 模型名
// @author sunct
func (p *TongyiProvider) getModelName() string {
if p.cfg.Model != "" {
return p.cfg.Model
}
return "qwen-turbo"
}
+87
View File
@@ -0,0 +1,87 @@
package bootstrap
import (
"context"
"fmt"
"os"
"resume-platform/internal/handler"
"resume-platform/internal/router"
"resume-platform/internal/security"
"resume-platform/pkg/config"
"resume-platform/pkg/logger"
)
func Run(ctx context.Context) error {
cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if err := initLogger(cfg); err != nil {
return fmt.Errorf("failed to initialize logger: %w", err)
}
if err := initDataDir(); err != nil {
return fmt.Errorf("failed to initialize data directory: %w", err)
}
logger.Infof("Configuration loaded - AdminPath: %s, Mode: %s", cfg.Auth.AdminPath, cfg.Server.Mode)
container, err := NewServiceContainer(cfg)
if err != nil {
return fmt.Errorf("failed to initialize service container: %w", err)
}
handler.SetResumeRepository(container.Repo)
handler.LoadTokens()
if err := initSecurity(cfg); err != nil {
return fmt.Errorf("failed to initialize security: %w", err)
}
if err := container.InitResumeAgent(ctx, cfg); err != nil {
logger.Warnf("Failed to initialize Resume Agent: %v", err)
}
if err := startServer(ctx, cfg, container); err != nil {
return fmt.Errorf("failed to start server: %w", err)
}
return nil
}
func initLogger(cfg *config.Config) error {
return logger.Init(logger.Config{
Level: cfg.Log.Level,
Output: cfg.Log.Output,
})
}
func initDataDir() error {
return os.MkdirAll("./data", 0755)
}
func initSecurity(cfg *config.Config) error {
security.InitCaptcha(160, 50)
return nil
}
func startServer(ctx context.Context, cfg *config.Config, container *ServiceContainer) error {
r := router.NewRouter(
container.ResumeHandler,
container.AdminHandler,
container.AIHandler,
container.DocumentHandler,
container.ChatHandler,
container.IMHandler,
container.KnowledgeHandler,
cfg,
)
engine := r.Setup()
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
logger.Infof("Server starting on %s", addr)
return engine.Run(addr)
}
+205
View File
@@ -0,0 +1,205 @@
package bootstrap
import (
"context"
"resume-platform/internal/agent"
aiprovider "resume-platform/internal/ai"
"resume-platform/internal/handler"
"resume-platform/internal/repository"
"resume-platform/internal/security"
"resume-platform/internal/service/chat"
"resume-platform/internal/service/document"
"resume-platform/internal/service/knowledge"
"resume-platform/internal/service/portfolio"
"resume-platform/internal/service/quiz"
"resume-platform/internal/service/resume"
"resume-platform/internal/service/system"
"resume-platform/internal/service/user"
aiservice "resume-platform/internal/service/ai"
"resume-platform/pkg/config"
)
type ServiceContainer struct {
// Repository
Repo repository.ResumeRepository
// Core Services
ResumeService resume.ResumeService
SystemService system.SystemService
DocumentService document.DocumentService
UserService user.UserService
PortfolioService portfolio.PortfolioService
QuizService quiz.QuizService
// AI Services
AIProvider aiprovider.Provider
AIService *aiservice.AIService
QuestionGenerator *aiservice.QuestionGeneratorService
ResumeGenerator *resume.ResumeGeneratorService
ResumeAgent *agent.ResumeAgent
// Knowledge Services
EmbeddingService *knowledge.EmbeddingService
VectorService *knowledge.VectorService
KnowledgeBaseService *knowledge.KnowledgeBaseService
RAGService *knowledge.RAGService
// Chat Services
ChatService chat.ChatService
IMService *chat.IMService
// Handlers
ResumeHandler *handler.ResumeHandler
AdminHandler *handler.AdminHandler
AIHandler *handler.AIHandler
DocumentHandler *handler.DocumentHandler
ChatHandler *handler.ChatHandler
IMHandler *handler.IMHandler
KnowledgeHandler *handler.KnowledgeHandler
}
func NewServiceContainer(cfg *config.Config) (*ServiceContainer, error) {
container := &ServiceContainer{}
if err := container.initRepository(cfg); err != nil {
return nil, err
}
if err := container.initAIServices(cfg); err != nil {
return nil, err
}
if err := container.initCoreServices(cfg); err != nil {
return nil, err
}
if err := container.initKnowledgeServices(cfg); err != nil {
return nil, err
}
if err := container.initChatServices(cfg); err != nil {
return nil, err
}
if err := container.initHandlers(cfg); err != nil {
return nil, err
}
return container, nil
}
func (c *ServiceContainer) initRepository(cfg *config.Config) error {
dbPath := cfg.Database.Path
if dbPath == "" {
dbPath = "./data/resume.db"
}
repo, err := repository.NewSQLiteRepository(dbPath, cfg.Database.Password)
if err != nil {
return err
}
c.Repo = repo
return nil
}
func (c *ServiceContainer) initCoreServices(cfg *config.Config) error {
c.ResumeService = resume.NewResumeService(c.Repo)
c.SystemService = system.NewSystemService(c.Repo)
if _, err := c.ResumeService.InitDefaultResume(); err != nil {
return err
}
if err := c.SystemService.InitDefaultMenus(); err != nil {
return err
}
c.DocumentService = document.NewDocumentService(c.Repo, c.ResumeGenerator)
c.UserService = user.NewUserService(c.Repo)
c.PortfolioService = portfolio.NewPortfolioService(c.Repo)
c.QuizService = quiz.NewQuizService(c.Repo)
return nil
}
func (c *ServiceContainer) initAIServices(cfg *config.Config) error {
if !cfg.AI.Enabled {
return nil
}
provider, err := aiprovider.NewProvider(&cfg.AI)
if err != nil {
return err
}
c.AIProvider = provider
c.AIService = aiservice.NewAIService(provider)
c.QuestionGenerator = aiservice.NewQuestionGeneratorService(provider)
c.ResumeGenerator = resume.NewResumeGeneratorService(provider)
return nil
}
func (c *ServiceContainer) initKnowledgeServices(cfg *config.Config) error {
if c.AIProvider == nil {
return nil
}
c.EmbeddingService = knowledge.NewEmbeddingService(c.AIProvider, c.Repo)
c.VectorService = knowledge.NewVectorService(c.EmbeddingService, c.Repo)
c.KnowledgeBaseService = knowledge.NewKnowledgeBaseService(c.EmbeddingService, c.VectorService, c.Repo)
c.RAGService = knowledge.NewRAGService(c.AIProvider, c.KnowledgeBaseService)
c.DocumentService = document.NewDocumentServiceWithKnowledge(c.Repo, c.ResumeGenerator, c.KnowledgeBaseService)
return nil
}
func (c *ServiceContainer) initChatServices(cfg *config.Config) error {
c.ChatService = chat.NewChatService(c.Repo, c.AIService)
c.IMService = chat.NewIMService(c.AIProvider, c.ResumeAgent)
return nil
}
func (c *ServiceContainer) initHandlers(cfg *config.Config) error {
c.ResumeHandler = handler.NewResumeHandler(
c.ResumeService,
c.DocumentService,
c.UserService,
c.PortfolioService,
c.QuizService,
c.SystemService,
)
rateLimiter := security.NewRateLimiter(cfg.Auth.MaxLoginAttempts, cfg.Auth.LockoutDuration)
c.AdminHandler = handler.NewAdminHandler(cfg, rateLimiter)
c.AIHandler = handler.NewAIHandler(c.AIService, c.QuestionGenerator, nil, c.ResumeService, c.QuizService)
c.DocumentHandler = handler.NewDocumentHandler(c.DocumentService)
c.ChatHandler = handler.NewChatHandler(c.ChatService)
c.IMHandler = handler.NewIMHandler(c.IMService)
if c.KnowledgeBaseService != nil {
c.KnowledgeHandler = handler.NewKnowledgeHandler(c.KnowledgeBaseService, c.RAGService)
}
return nil
}
func (c *ServiceContainer) InitResumeAgent(ctx context.Context, cfg *config.Config) error {
if !cfg.AI.Enabled || c.AIProvider == nil {
return nil
}
agent, err := agent.NewResumeAgent(ctx, &cfg.AI)
if err != nil {
return err
}
c.ResumeAgent = agent
c.AIHandler.SetResumeAgent(agent)
return nil
}
File diff suppressed because it is too large Load Diff
+594
View File
@@ -0,0 +1,594 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"resume-platform/internal/agent"
"resume-platform/internal/model"
aiservice "resume-platform/internal/service/ai"
"resume-platform/internal/service/quiz"
"resume-platform/internal/service/resume"
"resume-platform/pkg/logger"
"strings"
"github.com/gin-gonic/gin"
)
// AIHandler AI 能力处理器,封装简历润色、分析、题目生成、评分等 AI 相关接口
type AIHandler struct {
aiService *aiservice.AIService
questionGenerator *aiservice.QuestionGeneratorService
resumeAgent *agent.ResumeAgent
resumeService resume.ResumeService
quizService quiz.QuizService
}
// NewAIHandler 创建 AI 处理器实例
// @param aiService AI 服务层
// @param questionGenerator 题目生成服务层
// @param resumeAgent AI Agent 实例
// @param resumeService 简历服务层
// @param quizService 答题服务层
// @return *AIHandler 处理器实例
// @author sunct
func NewAIHandler(aiService *aiservice.AIService, questionGenerator *aiservice.QuestionGeneratorService, resumeAgent *agent.ResumeAgent, resumeService resume.ResumeService, quizService quiz.QuizService) *AIHandler {
return &AIHandler{
aiService: aiService,
questionGenerator: questionGenerator,
resumeAgent: resumeAgent,
resumeService: resumeService,
quizService: quizService,
}
}
// SetResumeAgent 设置 ResumeAgent(延迟初始化使用)
// @param resumeAgent AI Agent 实例
func (h *AIHandler) SetResumeAgent(resumeAgent *agent.ResumeAgent) {
h.resumeAgent = resumeAgent
}
// buildResumeText 将简历对象拼接为纯文本,用于 AI 关键词提取
// @param resume 简历数据
// @return string 拼接后的纯文本
// @author sunct
func buildResumeText(resume *model.Resume) string {
if resume == nil {
return ""
}
var sb strings.Builder
sb.WriteString(resume.BasicInfo.Name + " ")
sb.WriteString(resume.BasicInfo.Title + " ")
sb.WriteString(resume.BasicInfo.Summary + " ")
sb.WriteString(resume.BasicInfo.Industry + " ")
sb.WriteString(resume.BasicInfo.JobTarget + " ")
for _, e := range resume.Experience {
sb.WriteString(e.Company + " ")
sb.WriteString(e.Position + " ")
sb.WriteString(e.Description + " ")
for _, h := range e.Highlights {
sb.WriteString(h + " ")
}
}
for _, s := range resume.Skills {
sb.WriteString(s.Name + " ")
sb.WriteString(s.Category + " ")
}
for _, p := range resume.Projects {
sb.WriteString(p.Name + " ")
sb.WriteString(p.Description + " ")
for _, t := range p.TechStack {
sb.WriteString(t + " ")
}
}
return sb.String()
}
// PolishText 单字段润色接口,根据字段类型调用不同的 AI 润色 Prompt
// @param c Gin 上下文
// @author sunct
func (h *AIHandler) PolishText(c *gin.Context) {
var req struct {
Original string `json:"original"`
FieldType string `json:"field_type"`
Company string `json:"company,omitempty"`
Position string `json:"position,omitempty"`
ProjectName string `json:"project_name,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, err.Error())
return
}
if req.Original == "" {
BadRequest(c, "original text is required")
return
}
var result string
var err error
switch req.FieldType {
case "summary":
result, err = h.aiService.PolishSummary(c.Request.Context(), req.Original)
case "experience":
result, err = h.aiService.PolishExperience(c.Request.Context(), req.Original, req.Company, req.Position)
case "project":
result, err = h.aiService.PolishProject(c.Request.Context(), req.Original, req.ProjectName)
default:
result, err = h.aiService.PolishDescription(c.Request.Context(), req.Original, req.FieldType)
}
if err != nil {
Error(c, err.Error())
return
}
OK(c, gin.H{
"result": result,
"provider": h.aiService.GetProviderName(),
})
}
// GetProviderInfo 获取当前使用的 AI 供应商名称
// @param c Gin 上下文
// @author sunct
func (h *AIHandler) GetProviderInfo(c *gin.Context) {
provider := h.aiService.GetProviderName()
if h.resumeAgent != nil {
provider = h.resumeAgent.GetProvider()
}
OK(c, gin.H{"provider": provider})
}
// AgentPolish Agent 模式润色接口,优先使用 Agent 工具链,降级为普通 AI 润色
// @param c Gin 上下文
// @author sunct
func (h *AIHandler) AgentPolish(c *gin.Context) {
var req struct {
Original string `json:"original"`
FieldType string `json:"field_type"`
Company string `json:"company,omitempty"`
Position string `json:"position,omitempty"`
ProjectName string `json:"project_name,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, err.Error())
return
}
if req.Original == "" {
BadRequest(c, "original text is required")
return
}
context := ""
if req.Company != "" {
context += "公司:" + req.Company + " "
}
if req.Position != "" {
context += "职位:" + req.Position + " "
}
if req.ProjectName != "" {
context += "项目名称:" + req.ProjectName
}
if h.resumeAgent != nil {
query := fmt.Sprintf("请帮我优化简历的%s字段。原文:%s", getFieldTypeName(req.FieldType), req.Original)
if context != "" {
query += "。上下文:" + context
}
result, err := h.resumeAgent.Run(c.Request.Context(), query)
if err != nil {
Error(c, err.Error())
return
}
OK(c, gin.H{
"result": result,
"provider": h.resumeAgent.GetProvider(),
"agent_mode": true,
})
return
}
var result string
var err error
switch req.FieldType {
case "summary":
result, err = h.aiService.PolishSummary(c.Request.Context(), req.Original)
case "experience":
result, err = h.aiService.PolishExperience(c.Request.Context(), req.Original, req.Company, req.Position)
case "project":
result, err = h.aiService.PolishProject(c.Request.Context(), req.Original, req.ProjectName)
default:
result, err = h.aiService.PolishDescription(c.Request.Context(), req.Original, req.FieldType)
}
if err != nil {
Error(c, err.Error())
return
}
OK(c, gin.H{
"result": result,
"provider": h.aiService.GetProviderName(),
"agent_mode": false,
})
}
// AgentAnalyze 简历整体分析接口,调用 Agent 对简历进行专业评估
// @param c Gin 上下文
// @author sunct
func (h *AIHandler) AgentAnalyze(c *gin.Context) {
var req struct {
ResumeText string `json:"resume_text"`
TargetPosition string `json:"target_position,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, err.Error())
return
}
if req.ResumeText == "" {
BadRequest(c, "resume_text is required")
return
}
if h.resumeAgent != nil {
query := "请分析以下简历内容并提供专业评估:" + req.ResumeText
if req.TargetPosition != "" {
query += "。目标职位:" + req.TargetPosition
}
result, err := h.resumeAgent.Run(c.Request.Context(), query)
if err != nil {
Error(c, err.Error())
return
}
OK(c, gin.H{
"result": result,
"provider": h.resumeAgent.GetProvider(),
})
return
}
OK(c, gin.H{
"result": "Eino Agent not available",
"provider": h.aiService.GetProviderName(),
})
}
// GenerateQuestions AI 生成面试题接口,支持按简历内容或关键词出题
// @param c Gin 上下文
// @author sunct
func (h *AIHandler) GenerateQuestions(c *gin.Context) {
var req struct {
ResumeID string `json:"resume_id,omitempty"`
ResumeText string `json:"resume_text,omitempty"`
Keywords string `json:"keywords,omitempty"`
Types string `json:"types,omitempty"`
Seed string `json:"seed,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, err.Error())
return
}
if req.ResumeText == "" && req.ResumeID == "" && req.Keywords == "" {
BadRequest(c, "resume_text, resume_id or keywords is required")
return
}
resumeText := req.ResumeText
keywords := req.Keywords
// 当提供 resume_id 时,真正获取简历内容并提取文本
if req.ResumeID != "" {
resume, err := h.resumeService.GetResume(req.ResumeID)
if err != nil || resume == nil {
Error(c, "简历不存在")
return
}
text := buildResumeText(resume)
if text != "" {
// 简历文本作为基础,与已有 resumeText 合并
if resumeText != "" {
resumeText = resumeText + " " + text
} else {
resumeText = text
}
}
// 从简历技能中补充关键词
if resume != nil && len(resume.Skills) > 0 && keywords == "" {
var skillNames []string
for _, s := range resume.Skills {
if s.Name != "" {
skillNames = append(skillNames, s.Name)
}
}
if len(skillNames) > 0 {
keywords = strings.Join(skillNames, ",")
}
}
}
// 当只有 keywords 时,构造基础文本
if resumeText == "" && keywords != "" {
resumeText = "基于关键词:" + keywords
}
if resumeText == "" {
BadRequest(c, "无法获取简历内容或关键词")
return
}
ctx := c.Request.Context()
logger.CtxInfof(ctx, "[AIHandler] GenerateQuestions resume_id=%s, keywords=%s, resume_text_len=%d", req.ResumeID, keywords, len(resumeText))
var questions []map[string]interface{}
var err error
if h.questionGenerator != nil {
logger.CtxInfof(ctx, "[AIHandler] Using AI question generator")
questions, err = h.questionGenerator.GenerateQuestions(ctx, resumeText, keywords, req.Types)
if err != nil {
logger.CtxErrorf(ctx, "[AIHandler] GenerateQuestions failed: %v", err)
Error(c, err.Error())
return
}
} else {
logger.CtxInfof(ctx, "[AIHandler] Using fallback question generator")
questions, err = h.generateFallbackQuestions(ctx, resumeText, keywords, req.Types, req.Seed)
if err != nil {
logger.CtxErrorf(ctx, "[AIHandler] generateFallbackQuestions failed: %v", err)
Error(c, err.Error())
return
}
}
if len(questions) == 0 {
Error(c, "未找到与关键词匹配的题目,请尝试其他关键词")
return
}
OK(c, gin.H{"questions": questions})
}
// generateFallbackQuestions 备用题目生成方法,当 AI 服务不可用时使用本地题库
// @param resumeText 简历文本
// @param keywords 关键词
// @param types 题目类型
// @param seed 随机种子
// @return []map[string]interface{} 题目列表
// @return error 错误信息
// @author sunct
func (h *AIHandler) generateFallbackQuestions(ctx context.Context, resumeText, keywords, types, seed string) ([]map[string]interface{}, error) {
logger.CtxInfof(ctx, "[AIHandler] generateFallbackQuestions keywords=%s, types=%s", keywords, types)
tool := agent.NewGenerateQuestionsTool()
args := map[string]interface{}{
"resume_text": resumeText,
"keywords": keywords,
"types": types,
"seed": seed,
"random": true,
}
argsJSON, err := json.Marshal(args)
if err != nil {
logger.CtxErrorf(ctx, "[AIHandler] Failed to marshal arguments: %v", err)
return nil, fmt.Errorf("failed to marshal arguments: %w", err)
}
resultJSON, err := tool.InvokableRun(ctx, string(argsJSON))
if err != nil {
logger.CtxErrorf(ctx, "[AIHandler] tool.InvokableRun failed: %v", err)
return nil, err
}
var questions []map[string]interface{}
if err := json.Unmarshal([]byte(resultJSON), &questions); err != nil {
logger.CtxErrorf(ctx, "[AIHandler] Failed to parse questions: %v", err)
return nil, fmt.Errorf("failed to parse questions: %w", err)
}
logger.CtxInfof(ctx, "[AIHandler] generateFallbackQuestions success, count=%d", len(questions))
return questions, nil
}
// EvaluateAnswers 本地答题评分接口,选择/填空按答案比对,简答按非空判定
// @param c Gin 上下文
// @author sunct
func (h *AIHandler) EvaluateAnswers(c *gin.Context) {
var req struct {
Questions []map[string]interface{} `json:"questions"`
Answers map[string]string `json:"answers"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, err.Error())
return
}
if len(req.Questions) == 0 {
BadRequest(c, "questions is required")
return
}
correctCount := 0
for i, question := range req.Questions {
answerKey := fmt.Sprintf("%d", i)
userAnswer := req.Answers[answerKey]
correctAnswer := question["answer"]
if correctAnswer == nil {
continue
}
correctStr := fmt.Sprintf("%v", correctAnswer)
if question["type"] == "mcq" || question["type"] == "fill" {
if strings.EqualFold(strings.TrimSpace(userAnswer), strings.TrimSpace(correctStr)) {
correctCount++
}
} else if question["type"] == "sa" {
if len(strings.TrimSpace(userAnswer)) > 0 {
correctCount++
}
}
}
totalQuestions := len(req.Questions)
score := float64(correctCount) / float64(totalQuestions) * 100
OK(c, gin.H{
"score": score,
"correct_count": correctCount,
"total_count": totalQuestions,
})
}
// SubmitAnswers 提交答题记录接口,计算得分并保存答题记录到数据库
// @param c Gin 上下文
// @author sunct
func (h *AIHandler) SubmitAnswers(c *gin.Context) {
var req struct {
Questions []map[string]interface{} `json:"questions"`
UserAnswers map[string]string `json:"user_answers"`
Score float64 `json:"score"`
CorrectCount int `json:"correct_count"`
TotalCount int `json:"total_count"`
UnansweredCount int `json:"unanswered_count"`
UserID string `json:"user_id,omitempty"`
ResumeID string `json:"resume_id,omitempty"`
ResumeRoute string `json:"resume_route,omitempty"`
Title string `json:"title,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, err.Error())
return
}
if len(req.Questions) == 0 {
BadRequest(c, "questions is required")
return
}
questions := make([]model.QuizQuestion, 0, len(req.Questions))
for _, q := range req.Questions {
question := model.QuizQuestion{
Type: getStringValue(q, "type"),
Text: getStringValue(q, "text"),
Answer: getStringValue(q, "answer"),
Analysis: getStringValue(q, "analysis"),
Score: getIntValue(q, "score"),
Difficulty: getStringValue(q, "difficulty"),
Category: getStringValue(q, "category"),
}
if options, ok := q["options"].([]interface{}); ok {
question.Options = make([]string, 0, len(options))
for _, opt := range options {
question.Options = append(question.Options, fmt.Sprintf("%v", opt))
}
}
if keywords, ok := q["keywords"].([]interface{}); ok {
question.Keywords = make([]string, 0, len(keywords))
for _, kw := range keywords {
question.Keywords = append(question.Keywords, fmt.Sprintf("%v", kw))
}
}
questions = append(questions, question)
}
record := &model.QuizRecord{
UserID: req.UserID,
ResumeID: req.ResumeID,
ResumeRoute: req.ResumeRoute,
Title: req.Title,
Questions: questions,
UserAnswers: req.UserAnswers,
Score: req.Score,
CorrectCount: req.CorrectCount,
TotalCount: req.TotalCount,
}
if h.quizService != nil {
err := h.quizService.CreateQuizRecord(record)
if err != nil {
logger.CtxErrorf(c.Request.Context(), "[AIHandler] SubmitAnswers failed to save record: %v", err)
}
}
message := fmt.Sprintf("答题完成!得分:%.1f分", req.Score)
if req.UnansweredCount > 0 {
message += fmt.Sprintf("%d/%d正确,%d题未作答)", req.CorrectCount, req.TotalCount-req.UnansweredCount, req.UnansweredCount)
} else {
message += fmt.Sprintf("%d/%d正确)", req.CorrectCount, req.TotalCount)
}
OK(c, gin.H{
"message": message,
"score": req.Score,
"correct_count": req.CorrectCount,
"total_count": req.TotalCount,
"unanswered_count": req.UnansweredCount,
"record_id": record.ID,
}, message)
}
func getStringValue(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
}
func getIntValue(m map[string]interface{}, key string) int {
if v, ok := m[key]; ok {
switch val := v.(type) {
case int:
return val
case float64:
return int(val)
default:
return 0
}
}
return 0
}
// getFieldTypeName 将字段类型英文标识转换为中文名称
// @param fieldType 字段类型
// @return string 中文名称
// @author sunct
func getFieldTypeName(fieldType string) string {
switch fieldType {
case "summary":
return "个人简介"
case "experience":
return "工作经历"
case "project":
return "项目描述"
case "education":
return "教育背景"
case "skill":
return "技能描述"
default:
return "内容"
}
}
+71
View File
@@ -0,0 +1,71 @@
package handler
import (
"io"
"net/http"
"resume-platform/internal/service/chat"
"github.com/gin-gonic/gin"
)
// ChatHandler 聊天对话处理器,基于文档向量库进行问答
type ChatHandler struct {
chatService chat.ChatService
}
// NewChatHandler 创建聊天处理器实例
// @param chatService 聊天服务层
// @return *ChatHandler 处理器实例
// @author sunct
func NewChatHandler(chatService chat.ChatService) *ChatHandler {
return &ChatHandler{chatService: chatService}
}
// Chat 流式对话接口,以 SSE 方式返回 AI 回答
// @param c Gin 上下文
// @author sunct
func (h *ChatHandler) Chat(c *gin.Context) {
userID := c.Query("user_id")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
var request struct {
Question string `json:"question"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
c.Stream(func(w io.Writer) bool {
answer, err := h.chatService.Chat(c.Request.Context(), userID, request.Question)
if err != nil {
c.SSEvent("error", gin.H{"message": err.Error()})
return false
}
c.SSEvent("message", answer)
return true
})
}
// GetChatHistory 获取用户聊天历史记录接口
// @param c Gin 上下文
// @author sunct
func (h *ChatHandler) GetChatHistory(c *gin.Context) {
userID := c.Query("user_id")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
history, err := h.chatService.GetChatHistory(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, history)
}
+124
View File
@@ -0,0 +1,124 @@
package handler
import (
"io"
"net/http"
"path/filepath"
"resume-platform/internal/service/document"
"github.com/gin-gonic/gin"
)
// DocumentHandler 文档处理器,负责作品集文档的上传、查询和删除
type DocumentHandler struct {
documentService document.DocumentService
}
// NewDocumentHandler 创建文档处理器实例
// @param documentService 文档服务层
// @return *DocumentHandler 处理器实例
// @author sunct
func NewDocumentHandler(documentService document.DocumentService) *DocumentHandler {
return &DocumentHandler{documentService: documentService}
}
// UploadDocument 上传文档接口,读取文件内容后交给 Service 层做向量化处理
// @param c Gin 上下文
// @author sunct
func (h *DocumentHandler) UploadDocument(c *gin.Context) {
userID := c.Query("user_id")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
fileType := filepath.Ext(file.Filename)
reader, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"})
return
}
defer reader.Close()
content, err := io.ReadAll(reader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
document, err := h.documentService.UploadDocument(c.Request.Context(), userID, file.Filename, fileType, file.Size, string(content))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": document.ID, "name": document.Name, "status": document.Status})
}
// GetDocuments 获取用户文档列表接口
// @param c Gin 上下文
// @author sunct
func (h *DocumentHandler) GetDocuments(c *gin.Context) {
userID := c.Query("user_id")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
documents, err := h.documentService.GetDocuments(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, documents)
}
// GetDocument 获取单篇文档详情接口
// @param c Gin 上下文
// @author sunct
func (h *DocumentHandler) GetDocument(c *gin.Context) {
userID := c.Query("user_id")
documentID := c.Param("id")
if userID == "" || documentID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id and document_id are required"})
return
}
document, err := h.documentService.GetDocument(c.Request.Context(), userID, documentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
return
}
c.JSON(http.StatusOK, document)
}
// DeleteDocument 删除文档接口
// @param c Gin 上下文
// @author sunct
func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
userID := c.Query("user_id")
documentID := c.Param("id")
if userID == "" || documentID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id and document_id are required"})
return
}
err := h.documentService.DeleteDocument(c.Request.Context(), userID, documentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "document deleted successfully"})
}
+163
View File
@@ -0,0 +1,163 @@
package handler
import (
"context"
"encoding/json"
"io"
"net/http"
"resume-platform/internal/service/chat"
"resume-platform/pkg/logger"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type IMHandler struct {
imService *chat.IMService
}
func NewIMHandler(imService *chat.IMService) *IMHandler {
return &IMHandler{imService: imService}
}
func (h *IMHandler) WebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Errorf("Failed to upgrade connection: %v", err)
return
}
defer conn.Close()
ctx := c.Request.Context()
logger.CtxInfof(ctx, "WebSocket connection opened")
for {
_, msg, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
logger.CtxInfof(ctx, "WebSocket connection closed normally")
} else {
logger.CtxErrorf(ctx, "WebSocket read error: %v", err)
}
break
}
var request struct {
Message string `json:"message"`
}
if err := json.Unmarshal(msg, &request); err != nil {
logger.CtxErrorf(ctx, "Failed to parse message: %v", err)
continue
}
if request.Message == "" {
continue
}
go h.handleMessage(ctx, conn, request.Message)
}
}
func (h *IMHandler) handleMessage(ctx context.Context, conn *websocket.Conn, message string) {
logger.CtxInfof(ctx, "Received message: %s", message)
agent := h.imService.GetResumeAgent()
var result string
var err error
if agent != nil {
logger.CtxInfof(ctx, "Using Eino Resume Agent for request")
result, err = h.imService.GenerateWithAgent(ctx, message)
} else {
logger.CtxInfof(ctx, "Using AI Provider for request")
result, err = h.imService.GetAIProvider().Generate(ctx, message)
}
if err != nil {
logger.CtxErrorf(ctx, "AI generate failed: %v", err)
response := map[string]interface{}{
"type": "error",
"message": err.Error(),
}
if err := conn.WriteJSON(response); err != nil {
logger.CtxErrorf(ctx, "Failed to send error response: %v", err)
}
return
}
response := map[string]interface{}{
"type": "message",
"content": result,
}
if err := conn.WriteJSON(response); err != nil {
logger.CtxErrorf(ctx, "Failed to send response: %v", err)
}
}
func (h *IMHandler) Chat(c *gin.Context) {
var request struct {
Message string `json:"message"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if request.Message == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "message is required"})
return
}
ctx := c.Request.Context()
logger.CtxInfof(ctx, "Chat request: %s", request.Message)
result, err := h.imService.GetAIProvider().Generate(ctx, request.Message)
if err != nil {
logger.CtxErrorf(ctx, "AI generate failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"response": result})
}
func (h *IMHandler) ChatStream(c *gin.Context) {
var request struct {
Message string `json:"message"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if request.Message == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "message is required"})
return
}
ctx := c.Request.Context()
logger.CtxInfof(ctx, "Chat stream request: %s", request.Message)
c.Stream(func(w io.Writer) bool {
result, err := h.imService.GetAIProvider().Generate(ctx, request.Message)
if err != nil {
logger.CtxErrorf(ctx, "AI generate failed: %v", err)
c.SSEvent("error", gin.H{"message": err.Error()})
return false
}
for _, char := range result {
c.SSEvent("message", string(char))
}
c.SSEvent("done", gin.H{})
return false
})
}
+168
View File
@@ -0,0 +1,168 @@
package handler
import (
"encoding/json"
"fmt"
"resume-platform/internal/model"
"resume-platform/internal/service/knowledge"
"github.com/gin-gonic/gin"
)
type KnowledgeHandler struct {
knowledgeBaseService *knowledge.KnowledgeBaseService
ragService *knowledge.RAGService
}
func NewKnowledgeHandler(knowledgeBaseService *knowledge.KnowledgeBaseService, ragService *knowledge.RAGService) *KnowledgeHandler {
return &KnowledgeHandler{knowledgeBaseService: knowledgeBaseService, ragService: ragService}
}
func (h *KnowledgeHandler) Search(c *gin.Context) {
query := c.Query("q")
topK := 5
if k := c.Query("k"); k != "" {
fmt.Sscanf(k, "%d", &topK)
}
userID := c.GetHeader("X-User-ID")
if query == "" {
BadRequest(c, "Query parameter is required")
return
}
results, err := h.knowledgeBaseService.Retrieve(c.Request.Context(), query, topK, userID)
if err != nil {
Error(c, err.Error())
return
}
OK(c, results)
}
func (h *KnowledgeHandler) GetStats(c *gin.Context) {
userID := c.GetHeader("X-User-ID")
stats, err := h.knowledgeBaseService.GetStats(c.Request.Context(), userID)
if err != nil {
Error(c, err.Error())
return
}
OK(c, stats)
}
func (h *KnowledgeHandler) AddDocument(c *gin.Context) {
var req struct {
DocumentID string `json:"document_id"`
Content string `json:"content"`
UserID string `json:"user_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "Invalid request body")
return
}
if req.DocumentID == "" || req.Content == "" {
BadRequest(c, "document_id and content are required")
return
}
err := h.knowledgeBaseService.AddDocument(c.Request.Context(), req.DocumentID, req.Content, req.UserID)
if err != nil {
Error(c, err.Error())
return
}
OK(c, gin.H{"message": "Document added to knowledge base successfully"})
}
func (h *KnowledgeHandler) AddResume(c *gin.Context) {
var req struct {
ResumeID string `json:"resume_id"`
Resume map[string]interface{} `json:"resume"`
UserID string `json:"user_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "Invalid request body")
return
}
if req.ResumeID == "" {
BadRequest(c, "resume_id is required")
return
}
resumeJSON, _ := json.Marshal(req.Resume)
var resume model.Resume
if err := json.Unmarshal(resumeJSON, &resume); err != nil {
BadRequest(c, "Invalid resume format")
return
}
resume.ID = req.ResumeID
resume.UserID = req.UserID
err := h.knowledgeBaseService.AddResume(c.Request.Context(), req.ResumeID, &resume)
if err != nil {
Error(c, err.Error())
return
}
OK(c, gin.H{"message": "Resume added to knowledge base successfully"})
}
func (h *KnowledgeHandler) AddText(c *gin.Context) {
var req struct {
Text string `json:"text"`
SourceType string `json:"source_type"`
SourceID string `json:"source_id"`
SourceName string `json:"source_name"`
UserID string `json:"user_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "Invalid request body")
return
}
if req.Text == "" || req.SourceType == "" {
BadRequest(c, "text and source_type are required")
return
}
err := h.knowledgeBaseService.AddText(c.Request.Context(), req.Text, req.SourceType, req.SourceID, req.SourceName, req.UserID)
if err != nil {
Error(c, err.Error())
return
}
OK(c, gin.H{"message": "Text added to knowledge base successfully"})
}
func (h *KnowledgeHandler) Chat(c *gin.Context) {
var req struct {
Query string `json:"query"`
UserID string `json:"user_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "Invalid request body")
return
}
if req.Query == "" {
BadRequest(c, "query is required")
return
}
result, err := h.ragService.Generate(c.Request.Context(), req.Query, req.UserID)
if err != nil {
Error(c, err.Error())
return
}
OK(c, gin.H{"answer": result})
}
+154
View File
@@ -0,0 +1,154 @@
// Package handler 处理所有 HTTP 请求,负责参数校验、调用 Service 层和返回响应
// 按业务模块分为简历管理、后台管理、AI 能力、文档处理、聊天对话等处理器
package handler
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
// APIResponse 统一 API 响应结构体,所有接口返回格式遵循此结构
type APIResponse struct {
Success bool `json:"success"` // 请求是否成功
Message string `json:"message,omitempty"`// 提示消息
Data interface{} `json:"data,omitempty"` // 响应数据
Error string `json:"error,omitempty"` // 错误信息
}
// OK 返回 200 成功响应,附带数据和可选消息
// @param c Gin 上下文
// @param data 响应数据
// @param message 可选提示消息
// @author sunct
func OK(c *gin.Context, data interface{}, message ...string) {
msg := ""
if len(message) > 0 {
msg = message[0]
}
c.JSON(http.StatusOK, APIResponse{
Success: true,
Data: data,
Message: msg,
})
}
// Created 返回 201 创建成功响应
// @param c Gin 上下文
// @param data 创建的资源数据
// @param message 可选提示消息
// @author sunct
func Created(c *gin.Context, data interface{}, message ...string) {
msg := ""
if len(message) > 0 {
msg = message[0]
}
c.JSON(http.StatusCreated, APIResponse{
Success: true,
Data: data,
Message: msg,
})
}
// Success 返回 200 成功响应,仅包含提示消息
// @param c Gin 上下文
// @param message 成功提示消息
// @author sunct
func Success(c *gin.Context, message string) {
c.JSON(http.StatusOK, APIResponse{
Success: true,
Message: message,
})
}
// BadRequest 返回 400 参数错误响应
// @param c Gin 上下文
// @param message 错误提示消息
// @author sunct
func BadRequest(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, APIResponse{
Success: false,
Message: message,
})
}
// NotFound 返回 404 资源不存在响应
// @param c Gin 上下文
// @param message 错误提示消息
// @author sunct
func NotFound(c *gin.Context, message string) {
c.JSON(http.StatusNotFound, APIResponse{
Success: false,
Message: message,
})
}
// Conflict 返回 409 资源冲突响应(如重复创建)
// @param c Gin 上下文
// @param message 错误提示消息
// @author sunct
func Conflict(c *gin.Context, message string) {
c.JSON(http.StatusConflict, APIResponse{
Success: false,
Message: message,
})
}
// Unauthorized 返回 401 未授权响应
// @param c Gin 上下文
// @param message 错误提示消息
// @author sunct
func Unauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, APIResponse{
Success: false,
Message: message,
})
}
// TooManyRequests 返回 429 请求过于频繁响应(限流)
// @param c Gin 上下文
// @param message 错误提示消息
// @author sunct
func TooManyRequests(c *gin.Context, message string) {
c.JSON(http.StatusTooManyRequests, APIResponse{
Success: false,
Message: message,
})
}
// Error 返回 500 服务器内部错误响应
// @param c Gin 上下文
// @param message 错误提示消息
// @author sunct
func Error(c *gin.Context, message string) {
c.JSON(http.StatusInternalServerError, APIResponse{
Success: false,
Message: message,
})
}
// SuccessResponse 返回成功响应(支持标准库 http.ResponseWriter
// @param w http.ResponseWriter
// @param data 响应数据
func SuccessResponse(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Data: data,
})
}
// ErrorResponse 返回错误响应(支持标准库 http.ResponseWriter
// @param w http.ResponseWriter
// @param statusCode HTTP 状态码
// @param message 错误消息
func ErrorResponse(w http.ResponseWriter, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(APIResponse{
Success: false,
Message: message,
})
}
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
// Package middleware Gin 中间件层,提供请求日志、认证等横切能力
package middleware
import (
"time"
"resume-platform/pkg/logger"
"github.com/gin-gonic/gin"
)
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
method := c.Request.Method
c.Next()
latency := time.Since(start)
statusCode := c.Writer.Status()
clientIP := c.ClientIP()
referer := c.Request.Referer()
userAgent := c.Request.UserAgent()
entry := logger.FromContext(c.Request.Context())
entry = entry.WithFields(map[string]interface{}{
"status": statusCode,
"latency": latency,
"ip": clientIP,
"method": method,
"path": path,
})
if referer != "" {
entry = entry.WithField("referer", referer)
}
if len(c.Errors) > 0 {
entry = entry.WithField("errors", c.Errors.String())
}
switch {
case statusCode >= 500:
entry.WithField("user_agent", userAgent).Error("Server error")
case statusCode >= 400:
entry.Warn("Client error")
default:
entry.Info("Request handled")
}
}
}
+25
View File
@@ -0,0 +1,25 @@
package middleware
import (
"context"
"resume-platform/pkg/constant"
"resume-platform/pkg/idgen"
"github.com/gin-gonic/gin"
)
func Tracing(appName string) gin.HandlerFunc {
return func(c *gin.Context) {
reqId, _ := idgen.GetID()
ctx := context.WithValue(c.Request.Context(), constant.RequestIdKey, reqId)
ctx = context.WithValue(ctx, constant.AppNameKey, appName)
ctx = context.WithValue(ctx, constant.RequestRouteKey, c.Request.URL.Path)
ctx = context.WithValue(ctx, constant.ClientIP, c.ClientIP())
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
+31
View File
@@ -0,0 +1,31 @@
// Package model 常量定义,包含系统默认配置和全局常量
package model
const (
// DefaultPageSize 后台列表默认分页大小
DefaultPageSize = 10
// MaxPageSize 后台列表最大分页大小
MaxPageSize = 100
// DefaultTemplate 默认简历模板名称
DefaultTemplate = "modern"
// AdminTokenFile 管理员令牌持久化文件路径
AdminTokenFile = "./data/admin_tokens.json"
// SettingsFile 系统设置持久化文件路径(预留)
SettingsFile = "./data/settings.json"
// DefaultWebsiteTitle 默认网站标题
DefaultWebsiteTitle = "景笺 - 开源简历平台"
// DefaultWebsiteDesc 默认网站简介
DefaultWebsiteDesc = "现代化的开源简历平台,支持多人简历管理、自定义模板、实时预览"
// DefaultWebsiteKeywords 默认网站关键词
DefaultWebsiteKeywords = "简历平台,开源,个人主页,作品集,简历管理"
// DefaultWebsiteDomain 默认网站域名
DefaultWebsiteDomain = "http://localhost:8090"
// DefaultAdminUsername 默认管理员用户名
DefaultAdminUsername = "admin"
// DefaultAdminPassword 默认管理员密码
DefaultAdminPassword = "admin123"
)
+95
View File
@@ -0,0 +1,95 @@
package model
// Document 上传文档模型,记录文档元数据和原始内容
type Document struct {
ID string `json:"id"` // 文档唯一标识
UserID string `json:"user_id"` // 关联的用户ID
Name string `json:"name"` // 文档名称
Type string `json:"type"` // 文件类型(扩展名)
Size int64 `json:"size"` // 文件大小(字节)
MIMEType string `json:"mime_type"` // MIME类型
Content string `json:"content"` // 文档原始内容
Status string `json:"status"` // 处理状态:uploading/processing/processed/failed
ErrorMsg string `json:"error_msg,omitempty"` // 处理失败的错误信息
CreatedAt string `json:"created_at"` // 创建时间
UpdatedAt string `json:"updated_at"` // 更新时间
}
// DocumentChunk 文档切片模型,用于向量检索的文本块
type DocumentChunk struct {
ID string `json:"id"` // 切片唯一标识
DocumentID string `json:"document_id"` // 关联的文档ID
ChunkIndex int `json:"chunk_index"` // 切片序号
Content string `json:"content"` // 切片文本内容
Embedding string `json:"embedding"` // 向量嵌入(JSON格式)
Metadata string `json:"metadata"` // 元数据(JSON格式)
CreatedAt string `json:"created_at"` // 创建时间
}
// ChatHistory 聊天历史记录模型
type ChatHistory struct {
ID string `json:"id"` // 记录唯一标识
UserID string `json:"user_id"` // 关联的用户ID
Question string `json:"question"` // 用户问题
Answer string `json:"answer"` // AI回答
Context string `json:"context"` // 参考的文档上下文
CreatedAt string `json:"created_at"` // 创建时间
}
// Menu 后台菜单模型
type Menu struct {
ID string `json:"id"` // 菜单唯一标识
Name string `json:"name"` // 菜单名称
Icon string `json:"icon"` // 图标类名
Path string `json:"path"` // 路由路径
ParentID string `json:"parent_id"` // 父菜单ID
SortOrder int `json:"sort_order"` // 排序权重
IsFixed bool `json:"is_fixed"` // 是否固定菜单(不可删除)
IsEnabled bool `json:"is_enabled"` // 是否启用
CreatedAt string `json:"created_at"` // 创建时间
UpdatedAt string `json:"updated_at"` // 更新时间
}
// SystemConfig 系统配置模型,存储网站全局设置
type SystemConfig struct {
ID string `json:"id"` // 配置唯一标识
WebsiteDomain string `json:"website_domain"` // 网站域名
WebsiteLogo string `json:"website_logo"` // 网站LogoBase64
LogoPath string `json:"logo_path"` // Logo文件路径
WebsiteTitle string `json:"website_title"` // 网站标题
WebsiteDesc string `json:"website_desc"` // 网站简介(短)
WebsiteDescription string `json:"website_description"` // 网站描述(长)
WebsiteKeywords string `json:"website_keywords"` // 网站关键词
AdminEmail string `json:"admin_email"` // 管理员邮箱
AdminName string `json:"admin_name"` // 管理员名称
SEOKeywords string `json:"seo_keywords"` // SEO关键词
SEODescription string `json:"seo_description"` // SEO描述
DatabasePath string `json:"database_path"` // 数据库文件路径
AdminPageSize int `json:"admin_page_size"` // 后台列表分页大小
CreatedAt string `json:"created_at"` // 创建时间
UpdatedAt string `json:"updated_at"` // 更新时间
}
// LoginHistory 登录历史记录模型
type LoginHistory struct {
ID string `json:"id"` // 记录唯一标识
UserID string `json:"user_id"` // 关联的用户ID
UserName string `json:"user_name"` // 用户名
IP string `json:"ip"` // 登录IP
Location string `json:"location"` // 登录地点
UserAgent string `json:"user_agent"` // 浏览器UA
Success bool `json:"success"` // 是否登录成功
Message string `json:"message"` // 结果消息
CreatedAt string `json:"created_at"` // 创建时间
}
// Notification 系统通知模型
type Notification struct {
ID string `json:"id"`
Type string `json:"type"` // 通知类型:user/resume/portfolio/quiz/system
Title string `json:"title"` // 通知标题
Message string `json:"message"` // 通知内容
URL string `json:"url"` // 点击跳转链接
IsRead bool `json:"is_read"` // 是否已读
CreatedAt string `json:"created_at"` // 创建时间
}
+30
View File
@@ -0,0 +1,30 @@
package model
// Embedding 向量嵌入模型,用于知识库检索
type Embedding struct {
ID string `json:"id"` // 向量唯一标识
ContentHash string `json:"content_hash"` // 内容哈希(去重)
Embedding string `json:"embedding"` // 向量嵌入(JSON数组)
SourceType string `json:"source_type"` // 来源类型(document/resume/text
SourceID string `json:"source_id"` // 来源ID
SourceName string `json:"source_name"` // 来源名称
UserID string `json:"user_id"` // 关联的用户ID
CreatedAt string `json:"created_at"` // 创建时间
}
// ChunkWithScore 带相似度评分的文档片段,用于向量检索排序
type ChunkWithScore struct {
Content string `json:"content"` // 片段内容
Score float64 `json:"score"` // 相似度评分
Metadata string `json:"metadata"` // 元数据(含向量JSON
SourceID string `json:"source_id"` // 来源ID
SourceName string `json:"source_name"` // 来源名称
SourceType string `json:"source_type"` // 来源类型
}
// KnowledgeBaseStats 知识库统计信息
type KnowledgeBaseStats struct {
TotalDocuments int `json:"total_documents"` // 文档总数
TotalChunks int `json:"total_chunks"` // 切片总数
TotalEmbeddings int `json:"total_embeddings"` // 向量总数
}
+170
View File
@@ -0,0 +1,170 @@
// Package model 数据模型层,定义所有业务实体结构
// 包含用户、简历、作品集、答题记录等核心数据模型
package model
// User 用户模型,用于用户认证和权限管理
type User struct {
ID string `json:"id"` // 用户唯一标识
Username string `json:"username"` // 用户名(唯一)
PasswordHash string `json:"password_hash"` // 密码哈希值
Email string `json:"email"` // 用户邮箱
Name string `json:"name"` // 用户姓名
Phone string `json:"phone"` // 用户手机号
Role string `json:"role"` // 用户角色(admin/user
CreatedAt string `json:"created_at"` // 创建时间
UpdatedAt string `json:"updated_at"` // 更新时间
}
// Resume 简历模型,存储个人简历的完整信息
type Resume struct {
ID string `json:"id"` // 简历唯一标识
UserID string `json:"user_id"` // 关联的用户ID
Route string `json:"route"` // 访问路由(唯一,如 zhangsan)
Password string `json:"password"` // 简历密码(用于访问保护)
BasicInfo BasicInfo `json:"basic_info"` // 基本信息
Experience []Experience `json:"experience"` // 工作经历列表
Education []Education `json:"education"` // 教育背景列表
Skills []Skill `json:"skills"` // 技能列表
Projects []Project `json:"projects"` // 项目经验列表
Template string `json:"template"` // 使用的模板名称(默认modern)
ShowInHome bool `json:"show_in_home"` // 是否在首页展示
CreatedAt string `json:"created_at"` // 创建时间
UpdatedAt string `json:"updated_at"` // 更新时间
}
// BasicInfo 基本信息,包含个人基本资料
type BasicInfo struct {
Name string `json:"name"` // 姓名
Title string `json:"title"` // 职位/头衔
Email string `json:"email"` // 邮箱地址
Phone string `json:"phone"` // 手机号码
Location string `json:"location"` // 所在地
Website string `json:"website"` // 个人网站地址
Summary string `json:"summary"` // 个人简介
Avatar string `json:"avatar"` // 头像URL
ExperienceYears string `json:"experience_years"` // 工作年限
JobTarget string `json:"job_target"` // 求职目标
Industry string `json:"industry"` // 所属行业
CoreAdvantages []string `json:"core_advantages"` // 核心优势列表
}
// Experience 工作经历,记录职业发展历程
type Experience struct {
ID uint `json:"id"` // 经历唯一标识
ResumeID string `json:"resume_id"` // 关联的简历ID
Company string `json:"company"` // 公司名称
Position string `json:"position"` // 职位名称
StartDate string `json:"start_date"` // 开始日期(格式:YYYY-MM
EndDate string `json:"end_date"` // 结束日期(格式:YYYY-MM"至今"表示当前)
Description string `json:"description"` // 工作描述
Highlights []string `json:"highlights"` // 工作亮点列表
Platforms []string `json:"platforms"` // 线上平台列表
}
// Education 教育背景,记录学习经历
type Education struct {
ID uint `json:"id"` // 教育背景唯一标识
ResumeID string `json:"resume_id"` // 关联的简历ID
School string `json:"school"` // 学校名称
Degree string `json:"degree"` // 学位(本科/硕士/博士等)
Major string `json:"major"` // 专业名称
StartDate string `json:"start_date"` // 入学日期(格式:YYYY-MM
EndDate string `json:"end_date"` // 毕业日期(格式:YYYY-MM
GPA string `json:"gpa"` // GPA成绩
}
// Skill 技能,记录个人技能和熟练程度
type Skill struct {
ID uint `json:"id"` // 技能唯一标识
ResumeID string `json:"resume_id"` // 关联的简历ID
Name string `json:"name"` // 技能名称
Level string `json:"level"` // 熟练程度(beginner/intermediate/advanced/expert
Category string `json:"category"` // 技能分类(后端/前端/数据库/运维等)
}
// Project 项目经验,记录参与的项目
type Project struct {
ID uint `json:"id"` // 项目唯一标识
ResumeID string `json:"resume_id"` // 关联的简历ID
Name string `json:"name"` // 项目名称
Description string `json:"description"` // 项目描述
TechStack []string `json:"tech_stack"` // 技术栈列表
URL string `json:"url"` // 项目链接
Highlights []string `json:"highlights"` // 项目亮点列表
Achievements []string `json:"achievements"` // 项目成果列表
StartDate string `json:"start_date"` // 开始日期(格式:YYYY-MM
EndDate string `json:"end_date"` // 结束日期(格式:YYYY-MM
ShowInResume bool `json:"show_in_resume"` // 是否在简历中显示
}
// PortfolioItem 作品集项目,用于展示详细的项目文档
type PortfolioItem struct {
ID uint `json:"id"` // 作品集项目唯一标识
UserID string `json:"user_id"` // 关联的用户ID
Name string `json:"name"` // 项目名称
Description string `json:"description"` // 项目描述
TechStack []string `json:"tech_stack"` // 技术栈列表
URL string `json:"url"` // 项目链接
StartDate string `json:"start_date"` // 开始日期(格式:YYYY-MM
EndDate string `json:"end_date"` // 结束日期(格式:YYYY-MM
Doc string `json:"doc"` // 项目文档(Markdown格式)
ShowInResume bool `json:"show_in_resume"` // 是否在简历中显示
ResumeID string `json:"resume_id"` // 关联的简历ID
ProjectID uint `json:"project_id"` // 关联的简历项目ID
Password string `json:"password"` // 访问密码
Hidden bool `json:"hidden"` // 是否隐藏
}
// ResumeUpdate 简历更新请求模型,用于部分更新简历内容
type ResumeUpdate struct {
BasicInfo *BasicInfo `json:"basic_info"` // 基本信息(可选)
Experience *[]Experience `json:"experience"` // 工作经历列表(可选)
Education *[]Education `json:"education"` // 教育背景列表(可选)
Skills *[]Skill `json:"skills"` // 技能列表(可选)
Projects *[]Project `json:"projects"` // 项目经验列表(可选)
Template *string `json:"template"` // 模板名称(可选)
ShowInHome *bool `json:"show_in_home"` // 是否在首页展示(可选)
Route *string `json:"route"` // 访问路由(可选)
Password *string `json:"password"` // 简历密码(可选)
}
// QuizQuestion 面试题题目模型
type QuizQuestion struct {
Type string `json:"type"` // 题目类型:mcq(选择题)、fill(填空题)、sa(简答题)、algo(算法设计题)
Text string `json:"text"` // 题目内容
Options []string `json:"options"` // 选项(选择题)
Answer string `json:"answer"` // 参考答案
Analysis string `json:"analysis"` // 答案解析(详细拓展)
Score int `json:"score"` // 分值
Keywords []string `json:"keywords"` // 关联关键词
Difficulty string `json:"difficulty"` // 难度级别:入门、初级、中级、进阶、高级
Category string `json:"category"` // 题目分类
}
// QuizRecord 面试题历史记录模型
type QuizRecord struct {
ID string `json:"id"` // 记录唯一标识
UserID string `json:"user_id"` // 关联的用户ID
ResumeID string `json:"resume_id"` // 关联的简历ID
ResumeRoute string `json:"resume_route"` // 关联的简历路由
Title string `json:"title"` // 答题记录标题
Questions []QuizQuestion `json:"questions"` // 题目列表
UserAnswers map[string]string `json:"user_answers"` // 用户答案
Score float64 `json:"score"` // 得分
CorrectCount int `json:"correct_count"` // 正确题数
TotalCount int `json:"total_count"` // 总题数
CreatedAt string `json:"created_at"` // 创建时间
}
// FavoriteQuestion 收藏/错题本模型
type FavoriteQuestion struct {
ID string `json:"id"` // 记录唯一标识
UserID string `json:"user_id"` // 关联的用户ID
ResumeID string `json:"resume_id"` // 关联的简历ID
ResumeRoute string `json:"resume_route"` // 关联的简历路由
Question QuizQuestion `json:"question"` // 题目内容
UserAnswer string `json:"user_answer"` // 用户答案
IsCorrect bool `json:"is_correct"` // 是否正确
IsFavorite bool `json:"is_favorite"` // 是否收藏
CreatedAt string `json:"created_at"` // 添加时间
}
+427
View File
@@ -0,0 +1,427 @@
package parser
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path/filepath"
"resume-platform/pkg/utils"
"strings"
)
type Parser interface {
Parse(fileName string, reader io.Reader) (string, error)
}
func NewParser(fileName string) (Parser, error) {
ext := strings.ToLower(filepath.Ext(fileName))
switch ext {
case ".pdf":
return &PDFParser{}, nil
case ".docx":
return &DOCXParser{}, nil
case ".xlsx":
return &ExcelParser{}, nil
case ".md", ".markdown":
return &MarkdownParser{}, nil
case ".txt":
return &TextParser{}, nil
default:
return &TextParser{}, nil
}
}
func ParseFile(fileName string, reader io.Reader) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
parser, err := NewParser(fileName)
if err != nil {
return "", err
}
text, err := parser.Parse(fileName, bytes.NewReader(data))
if err != nil {
return "", err
}
if text == "" && len(data) > 0 {
text = extractTextFromBinary(data)
}
return text, nil
}
func extractTextFromBinary(data []byte) string {
content := string(data)
var builder strings.Builder
for _, r := range content {
if r >= 0x20 && r <= 0x7E || r == '\n' || r == '\r' || r == '\t' ||
(r >= 0x4E00 && r <= 0x9FFF) || (r >= 0x3000 && r <= 0x303F) ||
(r >= 0xFF00 && r <= 0xFFEF) {
builder.WriteRune(r)
} else if r > 0 {
builder.WriteRune(' ')
}
}
return cleanText(builder.String())
}
type TextParser struct{}
func (p *TextParser) Parse(fileName string, reader io.Reader) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
return cleanText(string(data)), nil
}
type MarkdownParser struct{}
func (p *MarkdownParser) Parse(fileName string, reader io.Reader) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
return cleanText(string(data)), nil
}
type PDFParser struct{}
func (p *PDFParser) Parse(fileName string, reader io.Reader) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
content := string(data)
if !strings.HasPrefix(content, "%PDF-") {
return "", fmt.Errorf("not a valid PDF file")
}
text := extractTextFromPDF(content)
return cleanText(text), nil
}
func extractTextFromPDF(content string) string {
var builder strings.Builder
inTextBlock := false
inStream := false
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.Contains(line, "/Length") && !inStream {
continue
}
if strings.HasPrefix(line, "stream") {
inStream = true
continue
}
if strings.HasPrefix(line, "endstream") {
inStream = false
continue
}
if strings.Contains(line, "BT") {
inTextBlock = true
continue
}
if strings.Contains(line, "ET") {
inTextBlock = false
continue
}
if inTextBlock && !inStream {
if strings.HasPrefix(line, "(") && strings.HasSuffix(line, ")") {
text := line[1 : len(line)-1]
text = strings.ReplaceAll(text, "\\n", "\n")
text = strings.ReplaceAll(text, "\\r", "\r")
text = strings.ReplaceAll(text, "\\t", "\t")
text = strings.ReplaceAll(text, "\\(", "(")
text = strings.ReplaceAll(text, "\\)", ")")
text = strings.ReplaceAll(text, "\\/", "/")
builder.WriteString(text)
builder.WriteString(" ")
} else if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
content := line[1 : len(line)-1]
parts := strings.Fields(content)
for _, part := range parts {
if strings.HasPrefix(part, "(") && strings.HasSuffix(part, ")") {
text := part[1 : len(part)-1]
text = strings.ReplaceAll(text, "\\n", "\n")
text = strings.ReplaceAll(text, "\\r", "\r")
text = strings.ReplaceAll(text, "\\t", "\t")
builder.WriteString(text)
builder.WriteString(" ")
}
}
}
}
}
return strings.TrimSpace(builder.String())
}
type DOCXParser struct{}
func (p *DOCXParser) Parse(fileName string, reader io.Reader) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
fmt.Printf("DEBUG DOCXParser: file size: %d bytes\n", len(data))
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", fmt.Errorf("failed to open DOCX as zip: %w", err)
}
fmt.Printf("DEBUG DOCXParser: zip contains %d files\n", len(zipReader.File))
var text strings.Builder
foundDocument := false
for _, file := range zipReader.File {
fmt.Printf("DEBUG DOCXParser: zip file: %s (size: %d)\n", file.Name, file.UncompressedSize64)
if file.Name == "word/document.xml" {
foundDocument = true
f, err := file.Open()
if err != nil {
return "", fmt.Errorf("failed to open document.xml: %w", err)
}
defer f.Close()
xmlData, err := io.ReadAll(f)
if err != nil {
return "", fmt.Errorf("failed to read document.xml: %w", err)
}
fmt.Printf("DEBUG DOCXParser: document.xml size: %d bytes\n", len(xmlData))
parsedText := parseDOCXXML(string(xmlData))
fmt.Printf("DEBUG DOCXParser: parsed text from document.xml: %d chars\n", len(parsedText))
text.WriteString(parsedText)
} else if strings.HasPrefix(file.Name, "word/header") && strings.HasSuffix(file.Name, ".xml") {
f, err := file.Open()
if err != nil {
continue
}
defer f.Close()
xmlData, _ := io.ReadAll(f)
text.WriteString(parseDOCXXML(string(xmlData)))
text.WriteString("\n")
} else if strings.HasPrefix(file.Name, "word/footer") && strings.HasSuffix(file.Name, ".xml") {
f, err := file.Open()
if err != nil {
continue
}
defer f.Close()
xmlData, _ := io.ReadAll(f)
text.WriteString(parseDOCXXML(string(xmlData)))
text.WriteString("\n")
}
}
if !foundDocument {
fmt.Println("WARN DOCXParser: document.xml not found in zip")
return cleanText(extractTextFromBinary(data)), nil
}
result := cleanText(text.String())
fmt.Printf("DEBUG DOCXParser: final result length: %d chars\n", len(result))
if len(result) > 0 {
fmt.Printf("DEBUG DOCXParser: first 500 chars:\n%s\n", result[:utils.Min(len(result), 500)])
}
return result, nil
}
func parseDOCXXML(xmlContent string) string {
fmt.Printf("DEBUG parseDOCXXML: input length: %d chars\n", len(xmlContent))
xmlContent = removeNamespacePrefixes(xmlContent)
allTexts := extractTextContent(xmlContent)
var builder strings.Builder
for _, text := range allTexts {
text = strings.TrimSpace(text)
if text != "" {
builder.WriteString(text)
builder.WriteString("\n")
}
}
result := cleanText(builder.String())
fmt.Printf("DEBUG parseDOCXXML: extracted %d text elements, result length: %d\n", len(allTexts), len(result))
if len(result) > 0 {
fmt.Printf("DEBUG parseDOCXXML: first 500 chars:\n%s\n", result[:utils.Min(len(result), 500)])
}
if result == "" {
fmt.Println("WARN: Structured parsing returned empty, using fallback")
return cleanText(extractPlainTextFromXML(xmlContent))
}
return result
}
func extractTextContent(xmlContent string) []string {
var texts []string
inTag := false
currentText := ""
for _, r := range xmlContent {
if r == '<' {
inTag = true
if currentText != "" {
texts = append(texts, currentText)
currentText = ""
}
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
currentText += string(r)
}
}
if currentText != "" {
texts = append(texts, currentText)
}
return texts
}
func removeNamespacePrefixes(xmlContent string) string {
prefixes := []string{"w:", "r:", "wp:", "wpc:", "wps:", "wpg:", "wpi:", "wne:", "mc:", "o:", "v:", "w10:", "wpc:", "wpg:", "wps:", "a:", "m:"}
for _, prefix := range prefixes {
xmlContent = strings.ReplaceAll(xmlContent, "<"+prefix, "<")
xmlContent = strings.ReplaceAll(xmlContent, "</"+prefix, "</")
xmlContent = strings.ReplaceAll(xmlContent, " "+prefix, " ")
}
return xmlContent
}
func extractPlainTextFromXML(xmlContent string) string {
var builder strings.Builder
inTag := false
for _, r := range xmlContent {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
builder.WriteRune(r)
}
}
return builder.String()
}
type ExcelParser struct{}
func (p *ExcelParser) Parse(fileName string, reader io.Reader) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", fmt.Errorf("failed to open XLSX as zip: %w", err)
}
var text strings.Builder
for _, file := range zipReader.File {
if strings.HasPrefix(file.Name, "xl/worksheets/") && strings.HasSuffix(file.Name, ".xml") {
sheetName := strings.TrimPrefix(file.Name, "xl/worksheets/sheet")
sheetName = strings.TrimSuffix(sheetName, ".xml")
text.WriteString("===" + sheetName + "===\n\n")
f, err := file.Open()
if err != nil {
return "", fmt.Errorf("failed to open worksheet: %w", err)
}
defer f.Close()
xmlData, err := io.ReadAll(f)
if err != nil {
return "", fmt.Errorf("failed to read worksheet: %w", err)
}
text.WriteString(parseXLSXXML(string(xmlData)))
text.WriteString("\n\n")
}
}
return cleanText(text.String()), nil
}
func parseXLSXXML(xmlContent string) string {
type Cell struct {
Text string `xml:",chardata"`
}
type Row struct {
Cells []Cell `xml:"c"`
}
type SheetData struct {
Rows []Row `xml:"row"`
}
type Worksheet struct {
SheetData SheetData `xml:"sheetData"`
}
var ws Worksheet
err := xml.Unmarshal([]byte(xmlContent), &ws)
if err != nil {
return ""
}
var builder strings.Builder
for _, row := range ws.SheetData.Rows {
var cells []string
for _, cell := range row.Cells {
text := strings.TrimSpace(cell.Text)
if text != "" {
cells = append(cells, text)
}
}
if len(cells) > 0 {
builder.WriteString(strings.Join(cells, "\t"))
builder.WriteString("\n")
}
}
return strings.TrimSpace(builder.String())
}
func cleanText(text string) string {
return utils.CleanText(text)
}
+464
View File
@@ -0,0 +1,464 @@
package prompt
import "fmt"
var (
ResumeExtractor = `你是一位专业的中文简历信息提取专家。请仔细分析以下简历内容,全面提取所有信息并按指定格式输出。
【简历内容】
%s
【提取要求】
请全面分析文档内容,识别以下所有区块,并尽可能从已有信息中推断和补充缺失的字段:
1. 基本信息:
- name: 姓名(通常在文档最开头,可能是最大的字体)
- title: 当前职位/头衔(如"后端开发工程师"、"高级Java工程师"
- email: 邮箱地址(包含@符号,可能在文档开头或结尾)
- phone: 手机号码(11位数字,可能在文档开头或结尾)
- location: 所在城市(如"北京"、"上海",可能在基本信息区域)
- website: 个人网站或博客地址(包含http://或https://
- summary: 个人简介或自我评价(通常以"个人简介"、"自我评价"、"职业概述"等开头)
- experience_years: 工作年限(从工作经历计算,填数字,如3、5、8)
- job_target: 求职目标或意向岗位(如果没有明确说明,可从当前职位推断)
- industry: 所属行业(如互联网、教育、金融、电商,从公司名称或描述推断)
- core_advantages: 核心优势或个人亮点(从工作经历和项目经验中提炼)
2. 工作经历:
- company: 公司名称
- position: 职位名称
- start_date: 开始时间(格式YYYY-MM
- end_date: 结束时间(格式YYYY-MM"至今"或"当前"填"present"
- description: 工作职责描述(详细描述日常工作内容)
- highlights: 工作亮点或主要成就(用列表表示,从描述中提炼关键成果)
- platforms: 负责的线上平台或产品名称(如有)
3. 教育背景:
- school: 学校名称
- degree: 学位(本科/硕士/博士/大专/高中等)
- major: 专业名称
- start_date: 入学时间(格式YYYY-MM
- end_date: 毕业时间(格式YYYY-MM
- gpa: GPA成绩(如有)
4. 专业技能:
- name: 技能名称(如Java、Python、MySQL、Redis、Vue.js、Docker
- level: 熟练程度(入门/初级/中级/高级/精通,根据描述推断)
- category: 技能分类(后端/前端/数据库/运维/移动端/人工智能/工具/其他)
5. 项目经验:
- name: 项目名称
- description: 项目描述(项目的目的、规模、主要功能)
- tech_stack: 技术栈列表(从项目描述和工作经历中提取使用的技术)
- url: 项目链接(如有)
- highlights: 项目亮点(主要负责的工作、解决的问题)
- achievements: 项目成果(量化的成果、获得的奖励等)
- start_date: 开始时间(格式YYYY-MM
- end_date: 结束时间(格式YYYY-MM
【数据补充规则】
1. 如果工作年限没有明确说明,根据工作经历的时间跨度计算得出
2. 如果求职目标没有明确说明,可从当前职位或工作经历推断
3. 如果行业没有明确说明,从公司名称或工作描述推断
4. 如果技能的熟练程度没有明确说明,根据使用频率和描述推断(如"精通"、"熟练"、"熟悉"、"了解"
5. 如果技能分类不确定,根据技能性质归类(如Java属于后端,Vue.js属于前端,MySQL属于数据库)
6. 如果项目的技术栈没有明确说明,从项目描述中提取使用的技术
7. 如果项目亮点和成果没有明确说明,从项目描述中提炼关键贡献
8. 如果核心优势没有明确说明,从工作经历和项目经验中提炼3-5个关键点
【关键词识别】
- 基本信息区域关键词:姓名、电话、手机、邮箱、邮箱地址、地址、所在地、籍贯、个人简介、自我评价、职业概述、求职意向、工作年限
- 工作经历区域关键词:工作经历、工作经验、职业经历、工作背景、公司、任职时间、工作职责、负责内容
- 教育背景区域关键词:教育背景、学历、教育经历、毕业院校、学校、学位、专业
- 技能区域关键词:专业技能、技能特长、技术能力、掌握技能、熟练掌握、了解、精通
- 项目经验区域关键词:项目经验、项目经历、项目背景、项目名称、负责项目、参与项目
【重要提示】
1. 请按照文档中的实际内容提取,不要遗漏任何信息
2. 如果某个字段在简历中确实找不到,请填空字符串""或空数组[]
3. 时间格式统一为YYYY-MM"至今"或"当前"填"present"
4. 工作年限只填数字,不要填"年"字
5. 请确保提取的信息与文档内容完全一致
6. 如果发现冲突信息,请以文档中最新的信息为准
【输出格式】
请直接输出JSON,不要包含markdown代码块标记,格式如下:
{
"basic_info": {
"name": "",
"title": "",
"email": "",
"phone": "",
"location": "",
"website": "",
"summary": "",
"experience_years": "",
"job_target": "",
"industry": "",
"core_advantages": []
},
"experience": [
{
"company": "",
"position": "",
"start_date": "",
"end_date": "",
"description": "",
"highlights": [],
"platforms": []
}
],
"education": [
{
"school": "",
"degree": "",
"major": "",
"start_date": "",
"end_date": "",
"gpa": ""
}
],
"skills": [
{
"name": "",
"level": "",
"category": ""
}
],
"projects": [
{
"name": "",
"description": "",
"tech_stack": [],
"url": "",
"highlights": [],
"achievements": [],
"start_date": "",
"end_date": ""
}
]
}`
RAGAnswer = `你是一位专业的简历分析助手。请根据以下上下文信息回答用户的问题。
【上下文信息】
%s
【用户问题】
%s
【回答要求】
1. 优先使用上下文信息回答问题
2. 如果上下文没有相关信息,可以使用你的内部知识
3. 回答要准确、简洁,符合中文表达习惯
4. 如果无法回答,请明确说明`
PolishSummary = `你是一位专业的简历优化专家。请优化以下个人简介,使其更加专业、简洁、有吸引力:
原文:
%s
优化要求:
1. 突出核心竞争力和独特价值
2. 使用行动动词和量化成果
3. 保持专业但不过于生硬
4. 控制在150字以内
5. 保持原意,不要编造信息
请直接输出优化后的内容,不要包含其他解释。`
PolishExperience = `你是一位专业的简历优化专家。请优化以下工作经历描述,使其更加专业、量化、有说服力:
公司:%s
职位:%s
原描述:
%s
优化要求:
1. 使用STAR法则(情境-任务-行动-结果)
2. 突出量化成果和数据指标
3. 使用专业术语和行业关键词
4. 突出个人贡献而非团队成果
5. 保持原意,不要编造信息
请直接输出优化后的内容,不要包含其他解释。`
PolishProject = `你是一位专业的简历优化专家。请优化以下项目描述,使其更加专业、突出技术能力和成果:
项目名称:%s
原描述:
%s
优化要求:
1. 清晰描述技术架构和技术栈
2. 突出个人技术贡献和解决的难题
3. 量化项目成果和影响
4. 使用专业技术术语
5. 保持原意,不要编造信息
请直接输出优化后的内容,不要包含其他解释。`
PolishDescription = `你是一位专业的简历优化专家。请优化以下%s内容:
原文:
%s
优化要求:
1. 语言更加专业、精炼
2. 突出重点和亮点
3. 使用适当的行业术语
4. 保持原意,不要编造信息
5. 格式清晰易读
请直接输出优化后的内容,不要包含其他解释。`
DocumentAnalysis = `你是一位专业的文档分析助手。请根据以下文档内容回答用户问题。
文档内容:
%s
用户问题:%s
回答要求:
1. 仅基于提供的文档内容回答,不要编造信息
2. 如果文档内容不足以回答问题,请明确说明
3. 回答要简洁准确,避免冗长`
QuestionGeneration = `你是一位专业的技术面试官和出题专家。请根据以下简历内容和技术关键词,生成高质量的面试题目。
简历内容:
%s
技术关键词:%s
题目类型:%s
生成规则(必须严格遵守,否则任务失败):
1. 每种题目类型必须生成4道题目,不得少于4道,不得多于5道
2. 如果包含4种题型(选择题、填空题、简答题、算法设计题),必须生成16道题目(4×4)
3. 如果包含3种题型,必须生成12道题目(4×3)
4. 如果包含2种题型,必须生成8道题目(4×2)
5. 选择题必须有4个选项,正确答案必须是选项之一
6. 填空题必须有明确的填空位置,用___表示
7. 每道题必须有详细的解析,不少于50字
8. 答案必须完整准确,代码题要有可运行的代码示例
9. 题目难度在初级、入门、中级、进阶之间均匀分布
10. 每道题至少包含2个技术关键词
11. 输出格式必须为纯JSON数组,不要包含任何markdown标记、代码块标记或额外文字
12. JSON字符串中所有双引号必须使用反斜杠转义
JSON格式要求(必须严格遵守,每种类型4题):
[
{
"type": "mcq",
"text": "选择题题目内容",
"options": ["选项A", "选项B", "选项C", "选项D"],
"answer": "正确答案(如A、B、C、D",
"analysis": "题目解析,直接给出解析内容",
"score": 10,
"keywords": ["关键词1", "关键词2"],
"difficulty": "初级",
"category": "分类名称"
},
{
"type": "fill",
"text": "填空题题目内容(用___表示填空位置)",
"answer": "正确答案",
"analysis": "题目解析",
"score": 5,
"keywords": ["关键词1"],
"difficulty": "入门",
"category": "分类名称"
},
{
"type": "sa",
"text": "简答题题目内容",
"answer": "参考答案",
"analysis": "详细解析",
"score": 15,
"keywords": ["关键词1", "关键词2"],
"difficulty": "中级",
"category": "分类名称"
},
{
"type": "algo",
"text": "算法题题目内容",
"answer": "代码答案",
"analysis": "详细解析",
"score": 20,
"keywords": ["关键词1"],
"difficulty": "进阶",
"category": "算法"
}
]
请立即开始生成题目,严格按照上述规则输出JSON数组。如果题目数量不够或格式不正确,将被视为任务失败。`
QuestionGenerationForType = `你是一位专业的技术面试官和出题专家。题目和答案必须严谨专业,符合面试需求,不得出现错误或漏洞。
请根据以下简历内容和技术关键词,生成高质量的%s。
简历内容:
%s
技术关键词:%s
生成规则(必须严格遵守,否则任务失败):
1. 必须生成5道%s,不得少于5道
2. 选择题必须有4个选项,答案字段必须只包含选项字母A、B、C或D,不能包含选项内容文字
3. 选择题的选项必须清晰明确,互不重叠,只有一个正确答案
4. 填空题必须有明确的填空位置,用___表示
5. 每道题必须有详细的解析,不少于50字
6. 答案必须完整准确,代码题要有可运行的代码示例
7. 题目难度在初级、入门、中级、进阶之间均匀分布
8. 每道题至少包含2个技术关键词
9. 输出格式必须为纯JSON数组,不要包含任何markdown标记、代码块标记或额外文字
10. JSON字符串中所有双引号必须使用反斜杠转义
11. 确保题目和答案正确无误,避免出现逻辑漏洞或事实错误
JSON格式要求(必须严格遵守,生成5道%s):
[
%s
]
请立即开始生成题目,严格按照上述规则输出JSON数组。`
)
func BuildResumeExtractorPrompt(content string) string {
return fmt.Sprintf(ResumeExtractor, content)
}
func BuildRAGAnswerPrompt(context, query string) string {
return fmt.Sprintf(RAGAnswer, context, query)
}
func BuildPolishSummaryPrompt(original string) string {
return fmt.Sprintf(PolishSummary, original)
}
func BuildPolishExperiencePrompt(original, company, position string) string {
return fmt.Sprintf(PolishExperience, company, position, original)
}
func BuildPolishProjectPrompt(original, projectName string) string {
return fmt.Sprintf(PolishProject, projectName, original)
}
func BuildPolishDescriptionPrompt(original, fieldType string) string {
var fieldName string
switch fieldType {
case "summary":
fieldName = "个人简介"
case "experience":
fieldName = "工作经历"
case "project":
fieldName = "项目描述"
case "education":
fieldName = "教育背景"
case "skill":
fieldName = "技能描述"
default:
fieldName = "文本内容"
}
return fmt.Sprintf(PolishDescription, fieldName, original)
}
func BuildDocumentAnalysisPrompt(context, question string) string {
return fmt.Sprintf(DocumentAnalysis, context, question)
}
func BuildQuestionGenerationPrompt(resumeText string, keywords []string, types []string) string {
typeNames := map[string]string{
"mcq": "选择题",
"fill": "填空题",
"sa": "简答题",
"algo": "算法设计题",
}
var typeDesc string
for i, t := range types {
if i > 0 {
typeDesc += "、"
}
typeDesc += typeNames[t]
}
return fmt.Sprintf(QuestionGeneration, resumeText, joinKeywords(keywords), typeDesc)
}
func BuildQuestionGenerationPromptForType(resumeText string, keywords []string, qType string) string {
typeNames := map[string]string{
"mcq": "选择题",
"fill": "填空题",
"sa": "简答题",
"algo": "算法设计题",
}
typeName := typeNames[qType]
if typeName == "" {
typeName = qType
}
var formatExample string
switch qType {
case "mcq":
formatExample = `{
"type": "mcq",
"text": "题目内容",
"options": ["选项A内容", "选项B内容", "选项C内容", "选项D内容"],
"answer": "A",
"analysis": "详细解析,不少于50字,说明每个选项为什么对或错",
"score": 10,
"keywords": ["关键词1", "关键词2"],
"difficulty": "初级",
"category": "分类名称"
}`
case "fill":
formatExample = `{
"type": "fill",
"text": "题目内容(用___表示填空位置)",
"answer": "正确答案",
"analysis": "详细解析,不少于50字,解释答案的由来",
"score": 5,
"keywords": ["关键词1", "关键词2"],
"difficulty": "入门",
"category": "分类名称"
}`
case "sa":
formatExample = `{
"type": "sa",
"text": "题目内容",
"answer": "参考答案,完整准确",
"analysis": "详细解析,不少于100字,深入解释原理",
"score": 15,
"keywords": ["关键词1", "关键词2"],
"difficulty": "中级",
"category": "分类名称"
}`
case "algo":
formatExample = `{
"type": "algo",
"text": "题目内容",
"answer": "完整可运行的代码示例",
"analysis": "详细解析,不少于100字,解释算法思路和复杂度",
"score": 20,
"keywords": ["关键词1", "关键词2"],
"difficulty": "进阶",
"category": "算法"
}`
}
return fmt.Sprintf(QuestionGenerationForType, typeName, resumeText, joinKeywords(keywords), typeName, typeName, formatExample)
}
func joinKeywords(keywords []string) string {
var result string
for i, kw := range keywords {
if i > 0 {
result += ", "
}
result += kw
}
return result
}
+79
View File
@@ -0,0 +1,79 @@
package repository
import "resume-platform/internal/model"
// ResumeRepository 简历数据访问层接口,定义简历相关的数据操作方法
type ResumeRepository interface {
GetResume(id string) (*model.Resume, error)
GetResumeByRoute(route string) (*model.Resume, error)
RouteExists(route string) bool
GetAllResumes() ([]*model.Resume, error)
GetResumesByUserID(userID string) ([]*model.Resume, error)
GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error)
GetResumesWithFilter(page, pageSize int, userID, keyword, template string, showInHome *bool) ([]*model.Resume, int, error)
CreateResume(resume *model.Resume) error
UpdateResume(id string, update *model.ResumeUpdate) error
DeleteResume(id string) error
UpdateResumeUserID(resumeID, userID string) error
Exists(id string) bool
CreateUser(user *model.User) error
GetUserByUsername(username string) (*model.User, error)
GetUserByID(id string) (*model.User, error)
GetAllUsers() ([]*model.User, error)
GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error)
UpdateUser(id string, user *model.User) error
DeleteUser(id string) error
GetPortfolioItems() ([]*model.PortfolioItem, error)
GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error)
GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error)
GetPortfolioItem(id uint) (*model.PortfolioItem, error)
CreatePortfolioItem(item *model.PortfolioItem) error
UpdatePortfolioItem(id uint, item *model.PortfolioItem) error
DeletePortfolioItem(id uint) error
CreateQuizRecord(record *model.QuizRecord) error
GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error)
GetQuizRecordsByUserID(userID string) ([]*model.QuizRecord, error)
GetQuizRecordsByUserIDWithPagination(userID string, page, pageSize int) ([]*model.QuizRecord, int, error)
GetQuizRecord(id string) (*model.QuizRecord, error)
DeleteQuizRecord(id string) error
CreateFavoriteQuestion(fav *model.FavoriteQuestion) error
GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error)
GetFavoriteQuestionsByUserID(userID string) ([]*model.FavoriteQuestion, error)
DeleteFavoriteQuestion(id string) error
ToggleFavorite(id string) error
CreateDocument(doc *model.Document) error
GetDocument(id, userID string) (*model.Document, error)
GetDocumentByID(id string) (*model.Document, error)
GetDocumentsByUserID(userID string) ([]*model.Document, error)
UpdateDocument(doc *model.Document) error
DeleteDocument(id, userID string) error
CreateDocumentChunk(chunk *model.DocumentChunk) error
GetDocumentChunksByUserID(userID string) ([]*model.DocumentChunk, error)
GetAllDocumentChunks() ([]*model.DocumentChunk, error)
GetDocumentChunksByDocumentID(documentID string) ([]*model.DocumentChunk, error)
CreateEmbedding(embedding *model.Embedding) error
GetEmbeddingByContentHash(contentHash string) (*model.Embedding, error)
GetEmbeddingsByUserID(userID string) ([]*model.Embedding, error)
GetAllEmbeddings() ([]*model.Embedding, error)
DeleteEmbedding(id string) error
DeleteEmbeddingsBySource(sourceType, sourceID string) error
CreateChatHistory(history *model.ChatHistory) error
GetChatHistoryByUserID(userID string) ([]*model.ChatHistory, error)
GetAllMenus() ([]*model.Menu, error)
CreateMenu(menu *model.Menu) error
UpdateMenu(id string, menu *model.Menu) error
DeleteMenu(id string) error
GetSystemConfig() (*model.SystemConfig, error)
CreateSystemConfig(config *model.SystemConfig) error
UpdateSystemConfig(id string, config *model.SystemConfig) error
CleanSystemConfig() error
CreateLoginHistory(history *model.LoginHistory) error
GetLoginHistory(page, pageSize int) ([]*model.LoginHistory, int, error)
CreateNotification(n *model.Notification) error
GetNotifications() ([]*model.Notification, error)
GetUnreadNotificationCount() (int, error)
MarkNotificationAsRead(id string) error
MarkAllNotificationsAsRead() error
DeleteAllNotifications() error
DeleteNotification(id string) error
}
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
package router
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
// setupAdminRoutes 配置后台管理路由
// 登录入口(需配置路径):
// - GET /my-secret-admin-123/login 登录页面
// - POST /my-secret-admin-123/login 登录验证
// - GET /my-secret-admin-123/captcha 验证码
//
// 后台管理路由(登录后访问):
// - GET /dashboard 后台首页(仪表盘)
// - GET /dashboard/home 后台首页
// - GET /dashboard/users 人员列表页
// - GET /dashboard/user/:id/resume 用户简历管理页
// - GET /dashboard/user/:id/portfolio 用户作品集管理页
// - GET /dashboard/user/:id/quiz 用户面试题库页
// - GET /dashboard/menus 菜单管理页
// - GET /dashboard/settings 系统设置页
// - GET /dashboard/logout 退出登录
func (r *Router) setupAdminRoutes(engine *gin.Engine) {
adminPath := r.cfg.Auth.AdminPath
if adminPath == "" {
adminPath = "/admin"
}
fmt.Printf("Setting up admin login routes at: %s\n", adminPath)
loginGroup := engine.Group(adminPath)
{
loginGroup.GET("/login", r.adminHandler.LoginPage)
loginGroup.POST("/login", r.adminHandler.Login)
loginGroup.GET("/captcha", r.adminHandler.Captcha)
}
dashboard := engine.Group("/dashboard")
dashboard.Use(r.adminHandler.AuthMiddleware())
{
dashboard.GET("/", r.adminHandler.HomePage)
dashboard.GET("/home", r.adminHandler.HomePage)
dashboard.GET("/users", r.adminHandler.UsersPage)
dashboard.GET("/user", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/dashboard/users")
})
dashboard.GET("/resumes", r.adminHandler.ResumesPage)
dashboard.GET("/resume/add", r.adminHandler.ResumeAddPage)
dashboard.GET("/resume/:id/edit", r.adminHandler.ResumeEditPageWithoutUser)
dashboard.GET("/user/:id/resume", r.adminHandler.UserResumePage)
dashboard.GET("/user/:id/resume/:resume_id/edit", r.adminHandler.ResumeEditPage)
dashboard.GET("/portfolios", r.adminHandler.PortfoliosPage)
dashboard.GET("/user/:id/portfolio", r.adminHandler.UserPortfolioPage)
dashboard.GET("/portfolio/add", r.adminHandler.PortfolioAddPage)
dashboard.GET("/portfolio/:id/edit", r.adminHandler.PortfolioEditPage)
dashboard.GET("/quiz", r.adminHandler.QuizPage)
dashboard.GET("/user/:id/quiz", r.adminHandler.UserQuizPage)
dashboard.GET("/user/:id/quiz/generate", r.adminHandler.QuizGeneratePage)
dashboard.GET("/user/:id/quiz/record/:record_id", r.adminHandler.QuizRecordDetailPage)
dashboard.POST("/api/quiz/favorite/:id/toggle", r.adminHandler.ToggleFavoriteAPI)
dashboard.DELETE("/api/quiz/favorite/:id", r.resumeHandler.DeleteFavoriteQuestionAPI)
dashboard.GET("/settings", r.adminHandler.SettingsPage)
dashboard.GET("/logout", r.adminHandler.Logout)
dashboard.GET("/api/user/:id", r.resumeHandler.GetUserAPI)
dashboard.POST("/api/user", r.resumeHandler.CreateUserAPI)
dashboard.PUT("/api/user/:id", r.resumeHandler.UpdateUserAPI)
dashboard.DELETE("/api/user/:id", r.resumeHandler.DeleteUserAPI)
dashboard.GET("/api/users", r.resumeHandler.GetAllUsersAPI)
dashboard.GET("/api/settings", r.adminHandler.GetSettingsAPI)
dashboard.POST("/api/settings", r.adminHandler.UpdateSettingsAPI)
dashboard.POST("/api/settings/logo", r.adminHandler.UploadLogoAPI)
dashboard.GET("/api/settings/system-info", r.adminHandler.SystemInfoAPI)
dashboard.GET("/api/settings/login-history", r.adminHandler.LoginHistoryAPI)
dashboard.POST("/api/portfolio", r.resumeHandler.CreatePortfolioItemAPI)
dashboard.GET("/api/portfolio/:id", r.resumeHandler.GetPortfolioItemAPI)
dashboard.PUT("/api/portfolio/:id", r.resumeHandler.UpdatePortfolioItemAPI)
dashboard.DELETE("/api/portfolio/:id", r.resumeHandler.DeletePortfolioItemAPI)
dashboard.POST("/api/resume/create", r.resumeHandler.AdminCreateResumeAPI)
dashboard.POST("/api/resume/generate-from-document", r.resumeHandler.GenerateResumeFromDocumentAPI)
dashboard.GET("/api/resume/:id", r.resumeHandler.GetResumeByIDAPI)
dashboard.PUT("/api/resume/:id", r.resumeHandler.UpdateResumeByIDAPI)
dashboard.PUT("/api/resume/:id/show_in_home", r.resumeHandler.UpdateResumeShowInHomeAPI)
dashboard.PUT("/api/resume/:id/password", r.resumeHandler.UpdateResumePasswordAPI)
dashboard.PUT("/api/resume/:id/user", r.resumeHandler.UpdateResumeUserIDAPI)
dashboard.DELETE("/api/resume/:id", r.resumeHandler.DeleteResumeAPI)
dashboard.GET("/api/search", r.adminHandler.SearchAPI)
dashboard.GET("/api/notifications", r.adminHandler.GetNotificationsAPI)
dashboard.POST("/api/notifications/:id/read", r.adminHandler.MarkNotificationAsReadAPI)
dashboard.POST("/api/notifications/read-all", r.adminHandler.MarkAllNotificationsAsReadAPI)
dashboard.DELETE("/api/notifications", r.adminHandler.DeleteAllNotificationsAPI)
dashboard.GET("/api/notifications/unread-count", r.adminHandler.GetUnreadNotificationCountAPI)
}
}
+31
View File
@@ -0,0 +1,31 @@
package router
import "github.com/gin-gonic/gin"
// setupAPIRoutes 配置 API 路由,所有接口前缀为 /api
// 按模块分组:
// - /api/resume 简历相关接口
// - /api/portfolio 作品集相关接口
// - /api/user 用户相关接口
// - /api/quiz 面试题库相关接口
// - /api/ai AI 相关接口
// - /api/document 文档相关接口
// - /api/chat 聊天相关接口
// - /api/system 系统配置相关接口
// - /api/menu 菜单相关接口
// - /api/im 实时聊天/IM相关接口
func (r *Router) setupAPIRoutes(engine *gin.Engine) {
api := engine.Group("/api")
{
r.setupResumeAPIRoutes(api)
r.setupPortfolioAPIRoutes(api)
r.setupUserAPIRoutes(api)
r.setupQuizAPIRoutes(api)
r.setupAIAPIRoutes(api)
r.setupDocumentAPIRoutes(api)
r.setupChatAPIRoutes(api)
r.setupSystemAPIRoutes(api)
r.setupIMRoutes(api)
r.setupKnowledgeAPIRoutes(api)
}
}
+21
View File
@@ -0,0 +1,21 @@
package router
import "github.com/gin-gonic/gin"
// setupAIAPIRoutes 配置 AI 相关 API 路由
func (r *Router) setupAIAPIRoutes(api *gin.RouterGroup) {
aiGroup := api.Group("/ai")
{
aiGroup.POST("/polish", r.aiHandler.PolishText)
aiGroup.GET("/provider", r.aiHandler.GetProviderInfo)
agentGroup := aiGroup.Group("/agent")
{
agentGroup.POST("/polish", r.aiHandler.AgentPolish)
agentGroup.POST("/analyze", r.aiHandler.AgentAnalyze)
agentGroup.POST("/generate-questions", r.aiHandler.GenerateQuestions)
agentGroup.POST("/evaluate-answers", r.aiHandler.EvaluateAnswers)
agentGroup.POST("/submit-answers", r.aiHandler.SubmitAnswers)
}
}
}
+12
View File
@@ -0,0 +1,12 @@
package router
import "github.com/gin-gonic/gin"
// setupChatAPIRoutes 配置聊天相关 API 路由
func (r *Router) setupChatAPIRoutes(api *gin.RouterGroup) {
chatGroup := api.Group("/chat")
{
chatGroup.POST("", r.chatHandler.Chat)
chatGroup.GET("/history", r.chatHandler.GetChatHistory)
}
}
+14
View File
@@ -0,0 +1,14 @@
package router
import "github.com/gin-gonic/gin"
// setupDocumentAPIRoutes 配置文档相关 API 路由
func (r *Router) setupDocumentAPIRoutes(api *gin.RouterGroup) {
documentGroup := api.Group("/document")
{
documentGroup.POST("/upload", r.documentHandler.UploadDocument)
documentGroup.GET("/list", r.documentHandler.GetDocuments)
documentGroup.GET("/:id", r.documentHandler.GetDocument)
documentGroup.DELETE("/:id", r.documentHandler.DeleteDocument)
}
}
+12
View File
@@ -0,0 +1,12 @@
package router
import "github.com/gin-gonic/gin"
func (r *Router) setupIMRoutes(api *gin.RouterGroup) {
imGroup := api.Group("/im")
{
imGroup.GET("/ws", r.imHandler.WebSocket)
imGroup.POST("/chat", r.imHandler.Chat)
imGroup.POST("/chat/stream", r.imHandler.ChatStream)
}
}
+18
View File
@@ -0,0 +1,18 @@
package router
import "github.com/gin-gonic/gin"
func (r *Router) setupKnowledgeAPIRoutes(api *gin.RouterGroup) {
if r.knowledgeHandler == nil {
return
}
knowledgeGroup := api.Group("/knowledge")
{
knowledgeGroup.GET("/search", r.knowledgeHandler.Search)
knowledgeGroup.GET("/stats", r.knowledgeHandler.GetStats)
knowledgeGroup.POST("/docs", r.knowledgeHandler.AddDocument)
knowledgeGroup.POST("/resume", r.knowledgeHandler.AddResume)
knowledgeGroup.POST("/text", r.knowledgeHandler.AddText)
knowledgeGroup.POST("/chat", r.knowledgeHandler.Chat)
}
}
+15
View File
@@ -0,0 +1,15 @@
package router
import "github.com/gin-gonic/gin"
// setupPortfolioAPIRoutes 配置作品集相关 API 路由
func (r *Router) setupPortfolioAPIRoutes(api *gin.RouterGroup) {
portfolioGroup := api.Group("/portfolio")
{
portfolioGroup.GET("", r.resumeHandler.GetPortfolioItemsAPI)
portfolioGroup.POST("", r.resumeHandler.CreatePortfolioItemAPI)
portfolioGroup.GET("/:id", r.resumeHandler.GetPortfolioItemAPI)
portfolioGroup.PUT("/:id", r.resumeHandler.UpdatePortfolioItemAPI)
portfolioGroup.DELETE("/:id", r.resumeHandler.DeletePortfolioItemAPI)
}
}
+19
View File
@@ -0,0 +1,19 @@
package router
import "github.com/gin-gonic/gin"
// setupQuizAPIRoutes 配置面试题库相关 API 路由
func (r *Router) setupQuizAPIRoutes(api *gin.RouterGroup) {
quizGroup := api.Group("/quiz")
{
quizGroup.POST("/record", r.resumeHandler.CreateQuizRecordAPI)
quizGroup.GET("/records", r.resumeHandler.GetQuizRecordsAPI)
quizGroup.GET("/record/:id", r.resumeHandler.GetQuizRecordAPI)
quizGroup.DELETE("/record/:id", r.resumeHandler.DeleteQuizRecordAPI)
quizGroup.POST("/favorite", r.resumeHandler.CreateFavoriteQuestionAPI)
quizGroup.GET("/favorites", r.resumeHandler.GetFavoriteQuestionsAPI)
quizGroup.DELETE("/favorite/:id", r.resumeHandler.DeleteFavoriteQuestionAPI)
quizGroup.PUT("/favorite/:id/toggle", r.resumeHandler.ToggleFavoriteAPI)
}
}
+17
View File
@@ -0,0 +1,17 @@
package router
import "github.com/gin-gonic/gin"
// setupResumeAPIRoutes 配置简历相关 API 路由
func (r *Router) setupResumeAPIRoutes(api *gin.RouterGroup) {
resumeGroup := api.Group("/resume")
{
resumeGroup.GET("", r.resumeHandler.GetResumeAPI)
resumeGroup.POST("", r.resumeHandler.CreateResumeAPI)
resumeGroup.PUT("", r.resumeHandler.UpdateResumeAPI)
resumeGroup.DELETE("/:id", r.resumeHandler.DeleteResumeAPI)
resumeGroup.GET("/list", r.resumeHandler.GetAllResumesAPI)
resumeGroup.GET("/route-check", r.resumeHandler.RouteCheckAPI)
resumeGroup.POST("/generate-from-document", r.resumeHandler.GenerateResumeFromDocumentAPI)
}
}
+20
View File
@@ -0,0 +1,20 @@
package router
import "github.com/gin-gonic/gin"
// setupSystemAPIRoutes 配置系统配置和菜单相关 API 路由
func (r *Router) setupSystemAPIRoutes(api *gin.RouterGroup) {
systemGroup := api.Group("/system")
{
systemGroup.GET("/config", r.resumeHandler.GetSystemConfigAPI)
systemGroup.POST("/config", r.resumeHandler.UpdateSystemConfigAPI)
}
menuGroup := api.Group("/menu")
{
menuGroup.GET("/list", r.resumeHandler.GetAllMenusAPI)
menuGroup.POST("", r.resumeHandler.CreateMenuAPI)
menuGroup.PUT("/:id", r.resumeHandler.UpdateMenuAPI)
menuGroup.DELETE("/:id", r.resumeHandler.DeleteMenuAPI)
}
}
+17
View File
@@ -0,0 +1,17 @@
package router
import "github.com/gin-gonic/gin"
// setupUserAPIRoutes 配置用户相关 API 路由
func (r *Router) setupUserAPIRoutes(api *gin.RouterGroup) {
userGroup := api.Group("/user")
{
userGroup.POST("", r.resumeHandler.CreateUserAPI)
userGroup.GET("/list", r.resumeHandler.GetAllUsersAPI)
userGroup.GET("/:id", r.resumeHandler.GetUserAPI)
userGroup.PUT("/:id", r.resumeHandler.UpdateUserAPI)
userGroup.DELETE("/:id", r.resumeHandler.DeleteUserAPI)
userGroup.GET("/resumes", r.resumeHandler.GetResumesByUserAPI)
userGroup.GET("/portfolio", r.resumeHandler.GetPortfolioItemsByUserAPI)
}
}
+23
View File
@@ -0,0 +1,23 @@
package router
import "github.com/gin-gonic/gin"
// setupFrontendRoutes 配置前端页面路由
// 路由结构:
// - / 产品首页,展示项目介绍和所有简历列表
// - /:route 个人简历页面,如 /zhangsan
// - /:route/portfolio 个人作品集页面,如 /zhangsan/portfolio
// - /:route/contact 个人联系方式页面,如 /zhangsan/contact
// - /sitemap.xml 站点地图XML,用于搜索引擎优化
func (r *Router) setupFrontendRoutes(engine *gin.Engine) {
engine.GET("/", r.resumeHandler.ShowHome)
engine.GET("/sitemap.xml", r.resumeHandler.Sitemap)
engine.GET("/:route", r.resumeHandler.ShowResume)
engine.POST("/:route/verify-password", r.resumeHandler.VerifyPassword)
engine.GET("/:route/portfolio", r.resumeHandler.ShowPortfolio)
engine.GET("/:route/contact", r.resumeHandler.ShowContact)
engine.NoRoute(func(c *gin.Context) {
c.HTML(404, "web/404", nil)
})
}
+199
View File
@@ -0,0 +1,199 @@
// Package router 负责 HTTP 路由的注册与管理
// 按功能模块分为前台路由、后台管理路由和 API 路由三类
package router
import (
"html/template"
"net/http"
"resume-platform/internal/handler"
"resume-platform/internal/middleware"
"resume-platform/internal/model"
"resume-platform/pkg/config"
"github.com/gin-gonic/gin"
)
// Router 路由管理器,持有各模块 Handler 实例和全局配置
type Router struct {
resumeHandler *handler.ResumeHandler
adminHandler *handler.AdminHandler
aiHandler *handler.AIHandler
documentHandler *handler.DocumentHandler
chatHandler *handler.ChatHandler
imHandler *handler.IMHandler
knowledgeHandler *handler.KnowledgeHandler
cfg *config.Config
}
// NewRouter 创建路由管理器实例
// @param resumeHandler 简历相关处理器
// @param adminHandler 后台管理相关处理器
// @param aiHandler AI 相关处理器
// @param documentHandler 文档相关处理器
// @param chatHandler 聊天相关处理器
// @param cfg 全局配置
// @return *Router 路由管理器实例
// @author sunct
func NewRouter(resumeHandler *handler.ResumeHandler, adminHandler *handler.AdminHandler, aiHandler *handler.AIHandler, documentHandler *handler.DocumentHandler, chatHandler *handler.ChatHandler, imHandler *handler.IMHandler, knowledgeHandler *handler.KnowledgeHandler, cfg *config.Config) *Router {
return &Router{
resumeHandler: resumeHandler,
adminHandler: adminHandler,
aiHandler: aiHandler,
documentHandler: documentHandler,
chatHandler: chatHandler,
imHandler: imHandler,
knowledgeHandler: knowledgeHandler,
cfg: cfg,
}
}
// Setup 初始化 Gin 引擎并注册所有路由、中间件和模板函数
// 注册顺序:中间件 → 模板函数 → 静态资源 → API 路由 → 后台路由 → 前台路由 → 404 处理
// @return *gin.Engine 配置完成的 Gin 引擎
// @author sunct
func (r *Router) Setup() *gin.Engine {
gin.SetMode(r.cfg.Server.Mode)
engine := gin.New()
engine.Use(middleware.Tracing("resume-platform"))
engine.Use(middleware.RequestLogger())
engine.Use(gin.Recovery())
engine.SetFuncMap(template.FuncMap{
"dict": dict,
"set": set,
"merge": merge,
"slice": slice,
"append": appendFunc,
"substr": substr,
"first": func(n int, s interface{}) interface{} {
switch v := s.(type) {
case []*model.Resume:
if n > len(v) {
return v
}
return v[:n]
default:
return s
}
},
})
engine.LoadHTMLGlob("templates/web/*.html")
engine.Static("/static", "./static")
r.setupAPIRoutes(engine)
r.setupAdminRoutes(engine)
r.setupFrontendRoutes(engine)
engine.NoRoute(func(c *gin.Context) {
if len(c.Request.URL.Path) > 10 && c.Request.URL.Path[:10] == "/dashboard" {
r.adminHandler.DashboardNotFound(c)
return
}
c.Status(http.StatusNotFound)
c.HTML(http.StatusNotFound, "404.html", nil)
})
return engine
}
// dict 模板函数,根据传入的键值对构建 map[string]interface{}
// 传入参数必须为偶数个,按 key, value 交替排列
// @param values 键值对列表,格式:key1, value1, key2, value2...
// @return map[string]interface{} 构建的字典
// @author sunct
func dict(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, nil
}
m := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, nil
}
m[key] = values[i+1]
}
return m, nil
}
// set 模板函数,向 map 中设置键值对
// @param m 目标 map
// @param key 键名
// @param value 值
// @return interface{} 始终返回 nil
// @author sunct
func set(m map[string]interface{}, key string, value interface{}) interface{} {
m[key] = value
return nil
}
// merge 模板函数,将 src map 中的所有键值合并到 dest map
// @param dest 目标 map
// @param src 源 map
// @return map[string]interface{} 合并后的 dest
// @author sunct
func merge(dest map[string]interface{}, src map[string]interface{}) map[string]interface{} {
for k, v := range src {
dest[k] = v
}
return dest
}
// slice 模板函数,将传入的多个参数组装为切片
// @param values 任意数量的参数
// @return []interface{} 参数切片
// @author sunct
func slice(values ...interface{}) []interface{} {
return values
}
// appendFunc 模板函数,向切片末尾追加一个元素
// @param slice 目标切片
// @param value 待追加元素
// @return []interface{} 追加后的切片
// @author sunct
func appendFunc(slice []interface{}, value interface{}) []interface{} {
return append(slice, value)
}
// substr 模板函数,按 rune 截取字符串,支持中文等 Unicode 字符
// @param s 源字符串
// @param start 起始位置,支持负数(从末尾倒数)
// @param length 可选,截取长度;不传则截取到末尾
// @return string 截取后的子串
// @author sunct
func substr(s string, start int, length ...int) string {
if len(s) == 0 {
return ""
}
runes := []rune(s)
runeLen := len(runes)
startIdx := 0
if start < 0 {
startIdx = runeLen + start
if startIdx < 0 {
startIdx = 0
}
} else {
startIdx = start
}
if startIdx >= runeLen {
return ""
}
if len(length) == 0 {
return string(runes[startIdx:])
}
endIdx := startIdx + length[0]
if endIdx > runeLen {
endIdx = runeLen
}
return string(runes[startIdx:endIdx])
}
+62
View File
@@ -0,0 +1,62 @@
// Package security 安全模块,提供验证码、登录限流等安全防护能力
package security
import (
"encoding/base64"
"net/http"
"strings"
"github.com/mojocn/base64Captcha"
)
// CaptchaInstance 全局验证码实例
var CaptchaInstance *base64Captcha.Captcha
// InitCaptcha 初始化数字验证码
// @param width 验证码宽度
// @param height 验证码高度
// @author sunct
func InitCaptcha(width, height int) {
driver := base64Captcha.NewDriverDigit(
height,
width,
4,
0.7,
80,
)
CaptchaInstance = base64Captcha.NewCaptcha(driver, base64Captcha.DefaultMemStore)
}
// Verify 校验验证码答案
// @param token 验证码令牌
// @param code 用户输入的验证码
// @return bool 是否验证通过
// @author sunct
func Verify(token, code string) bool {
return base64Captcha.DefaultMemStore.Verify(token, code, true)
}
// ServeCaptcha 生成并输出验证码图片(PNG格式)
// @param w HTTP 响应写入器
// @author sunct
func ServeCaptcha(w http.ResponseWriter) {
if CaptchaInstance == nil {
InitCaptcha(160, 50)
}
id, b64s, _ := CaptchaInstance.Generate()
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("X-Captcha-Token", id)
prefix := "data:image/png;base64,"
if strings.HasPrefix(b64s, prefix) {
b64s = strings.TrimPrefix(b64s, prefix)
}
data, _ := base64.StdEncoding.DecodeString(b64s)
w.Write(data)
}
+93
View File
@@ -0,0 +1,93 @@
package security
import (
"sync"
"time"
)
// LoginAttempt 登录尝试记录
type LoginAttempt struct {
Count int // 尝试次数
LastTry time.Time // 最后尝试时间
LockedUntil time.Time // 锁定截止时间
}
// RateLimiter 登录限流器,基于 IP 防止暴力破解
type RateLimiter struct {
mu sync.Mutex // 互斥锁
attempts map[string]*LoginAttempt // 尝试记录映射
maxAttempts int // 最大尝试次数
lockoutDuration time.Duration // 锁定时长
}
// NewRateLimiter 创建登录限流器
// @param maxAttempts 最大尝试次数
// @param lockoutDuration 锁定时长(分钟)
// @return *RateLimiter 限流器实例
// @author sunct
func NewRateLimiter(maxAttempts int, lockoutDuration int) *RateLimiter {
return &RateLimiter{
attempts: make(map[string]*LoginAttempt),
maxAttempts: maxAttempts,
lockoutDuration: time.Duration(lockoutDuration) * time.Minute,
}
}
// Allow 检查 IP 是否允许登录
// @param ip 客户端IP
// @return bool 是否允许
// @return int 剩余尝试次数
// @author sunct
func (rl *RateLimiter) Allow(ip string) (bool, int) {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
if attempt, exists := rl.attempts[ip]; exists {
if now.Before(attempt.LockedUntil) {
return false, rl.maxAttempts - attempt.Count
}
if now.Sub(attempt.LastTry) > 1*time.Hour {
delete(rl.attempts, ip)
return true, rl.maxAttempts
}
if attempt.Count >= rl.maxAttempts {
attempt.LockedUntil = now.Add(rl.lockoutDuration)
return false, 0
}
}
return true, rl.maxAttempts
}
// RecordAttempt 记录一次登录尝试
// @param ip 客户端IP
// @author sunct
func (rl *RateLimiter) RecordAttempt(ip string) {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
if attempt, exists := rl.attempts[ip]; exists {
attempt.Count++
attempt.LastTry = now
} else {
rl.attempts[ip] = &LoginAttempt{
Count: 1,
LastTry: now,
}
}
}
// Reset 重置 IP 的登录尝试记录
// @param ip 客户端IP
// @author sunct
func (rl *RateLimiter) Reset(ip string) {
rl.mu.Lock()
defer rl.mu.Unlock()
delete(rl.attempts, ip)
}
+51
View File
@@ -0,0 +1,51 @@
package ai
import (
"context"
"resume-platform/internal/ai"
"resume-platform/internal/prompt"
)
type AIService struct {
provider ai.Provider
}
func NewAIService(provider ai.Provider) *AIService {
return &AIService{provider: provider}
}
func (s *AIService) PolishSummary(ctx context.Context, original string) (string, error) {
promptText := prompt.BuildPolishSummaryPrompt(original)
return s.provider.Generate(ctx, promptText)
}
func (s *AIService) PolishExperience(ctx context.Context, original string, company string, position string) (string, error) {
promptText := prompt.BuildPolishExperiencePrompt(original, company, position)
return s.provider.Generate(ctx, promptText)
}
func (s *AIService) PolishProject(ctx context.Context, original string, projectName string) (string, error) {
promptText := prompt.BuildPolishProjectPrompt(original, projectName)
return s.provider.Generate(ctx, promptText)
}
func (s *AIService) PolishDescription(ctx context.Context, original string, fieldType string) (string, error) {
promptText := prompt.BuildPolishDescriptionPrompt(original, fieldType)
return s.provider.Generate(ctx, promptText)
}
func (s *AIService) GetProviderName() string {
return s.provider.GetName()
}
func (s *AIService) Generate(ctx context.Context, prompt string) (string, error) {
return s.provider.Generate(ctx, prompt)
}
func (s *AIService) Embedding(ctx context.Context, text string) ([]float64, error) {
return nil, nil
}
+295
View File
@@ -0,0 +1,295 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"resume-platform/internal/ai"
"resume-platform/internal/prompt"
"resume-platform/pkg/logger"
"strings"
"sync"
)
type QuestionGeneratorService struct {
provider ai.Provider
}
func NewQuestionGeneratorService(provider ai.Provider) *QuestionGeneratorService {
return &QuestionGeneratorService{provider: provider}
}
func (s *QuestionGeneratorService) GenerateQuestions(ctx context.Context, resumeText, keywordsStr, typesStr string) ([]map[string]interface{}, error) {
if resumeText == "" && keywordsStr == "" {
return nil, fmt.Errorf("resume_text or keywords is required")
}
if resumeText == "" {
resumeText = "基于关键词:" + keywordsStr
}
techKeywords := extractTechKeywords(resumeText)
if len(techKeywords) == 0 {
techKeywords = []string{"HTML", "CSS", "JavaScript", "Go", "MySQL", "Redis"}
}
if keywordsStr != "" {
techKeywords = strings.Split(keywordsStr, ",")
}
selectedTypes := parseTypes(typesStr)
logger.CtxInfof(ctx, "[QuestionGenerator] Generating questions for keywords: %v, types: %v", techKeywords, selectedTypes)
var wg sync.WaitGroup
resultChan := make(chan []map[string]interface{}, len(selectedTypes))
errChan := make(chan error, len(selectedTypes))
for _, qType := range selectedTypes {
wg.Add(1)
go func(t string) {
defer wg.Done()
prompt := buildQuestionGenerationPromptForType(resumeText, techKeywords, t)
logger.CtxInfof(ctx, "[QuestionGenerator] Generating %s questions...", t)
result, err := s.provider.Generate(ctx, prompt)
if err != nil {
logger.CtxErrorf(ctx, "[QuestionGenerator] AI generate failed for %s: %v", t, err)
errChan <- err
return
}
questions, err := parseAIQuestionsWithCtx(ctx, result)
if err != nil {
logger.CtxErrorf(ctx, "[QuestionGenerator] Parse failed for %s: %v", t, err)
errChan <- err
return
}
if len(questions) > 0 {
resultChan <- questions
logger.CtxInfof(ctx, "[QuestionGenerator] Generated %d %s questions", len(questions), t)
}
}(qType)
}
go func() {
wg.Wait()
close(resultChan)
close(errChan)
}()
var allQuestions []map[string]interface{}
for questions := range resultChan {
allQuestions = append(allQuestions, questions...)
}
logger.CtxInfof(ctx, "[QuestionGenerator] Total generated %d questions", len(allQuestions))
if len(allQuestions) == 0 {
return nil, fmt.Errorf("未能生成任何题目")
}
return allQuestions, nil
}
func buildQuestionGenerationPrompt(resumeText string, keywords []string, types []string) string {
return prompt.BuildQuestionGenerationPrompt(resumeText, keywords, types)
}
func buildQuestionGenerationPromptForType(resumeText string, keywords []string, qType string) string {
return prompt.BuildQuestionGenerationPromptForType(resumeText, keywords, qType)
}
func parseAIQuestionsWithCtx(ctx context.Context, result string) ([]map[string]interface{}, error) {
result = strings.TrimSpace(result)
logger.CtxInfof(ctx, "[QuestionGenerator] AI response length: %d", len(result))
logger.CtxInfof(ctx, "[QuestionGenerator] AI response raw result (first 2000 chars): %s", substr(result, 0, 2000))
result = strings.ReplaceAll(result, "```json", "")
result = strings.ReplaceAll(result, "```", "")
result = strings.TrimSpace(result)
startIdx := strings.Index(result, "[")
endIdx := strings.LastIndex(result, "]")
if startIdx == -1 || endIdx == -1 || endIdx <= startIdx {
logger.CtxErrorf(ctx, "[QuestionGenerator] Cannot find JSON array boundaries")
return nil, fmt.Errorf("无法找到JSON数组边界")
}
jsonStr := result[startIdx : endIdx+1]
logger.CtxInfof(ctx, "[QuestionGenerator] Extracted JSON array (length=%d)", len(jsonStr))
var questions []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &questions); err != nil {
logger.CtxErrorf(ctx, "[QuestionGenerator] Failed to unmarshal as array: %v", err)
logger.CtxErrorf(ctx, "[QuestionGenerator] Raw result that failed (first 500 chars): %s", substr(jsonStr, 0, 500))
return nil, fmt.Errorf("AI返回的JSON格式不正确: %w", err)
}
var validQuestions []map[string]interface{}
for _, q := range questions {
if !isValidQuestion(q) {
logger.CtxWarnf(ctx, "[QuestionGenerator] Skipping invalid question: %v", q)
continue
}
q = normalizeQuestion(q)
validQuestions = append(validQuestions, q)
}
logger.CtxInfof(ctx, "[QuestionGenerator] Successfully parsed %d valid questions (filtered %d invalid)", len(validQuestions), len(questions)-len(validQuestions))
if len(validQuestions) == 0 {
return nil, fmt.Errorf("未解析到有效题目")
}
return validQuestions, nil
}
func isValidQuestion(q map[string]interface{}) bool {
if q == nil {
return false
}
qType := fmt.Sprintf("%v", q["type"])
text := fmt.Sprintf("%v", q["text"])
answer := fmt.Sprintf("%v", q["answer"])
if qType == "" || text == "" || answer == "" {
return false
}
if qType == "mcq" {
if options, ok := q["options"].([]interface{}); ok {
if len(options) != 4 {
return false
}
for _, opt := range options {
if fmt.Sprintf("%v", opt) == "" {
return false
}
}
} else {
return false
}
answer = strings.ToUpper(strings.TrimSpace(answer))
if len(answer) != 1 || (answer < "A" || answer > "D") {
return false
}
}
if qType == "fill" {
if !strings.Contains(text, "___") {
return false
}
}
if qType == "sa" || qType == "algo" {
if len(answer) < 10 {
return false
}
}
analysis := fmt.Sprintf("%v", q["analysis"])
if len(analysis) < 30 {
return false
}
return true
}
func normalizeQuestion(q map[string]interface{}) map[string]interface{} {
qType := fmt.Sprintf("%v", q["type"])
if qType == "mcq" {
answer := strings.TrimSpace(fmt.Sprintf("%v", q["answer"]))
if len(answer) > 0 {
q["answer"] = strings.ToUpper(answer[:1])
}
if options, ok := q["options"].([]interface{}); ok {
var optionStrings []string
for _, opt := range options {
optStr := strings.TrimSpace(fmt.Sprintf("%v", opt))
optionStrings = append(optionStrings, optStr)
}
q["options"] = optionStrings
}
}
if _, ok := q["score"]; !ok {
diff := fmt.Sprintf("%v", q["difficulty"])
q["score"] = getScoreByDifficulty(diff)
}
if _, ok := q["category"]; !ok {
q["category"] = "综合"
}
if _, ok := q["keywords"]; !ok {
q["keywords"] = []string{}
}
if _, ok := q["options"]; !ok {
q["options"] = []string{}
}
if _, ok := q["analysis"]; !ok {
q["analysis"] = ""
}
if _, ok := q["difficulty"]; !ok {
q["difficulty"] = "初级"
}
return q
}
func isSingleLetter(s string) bool {
return len(s) == 1 && (s >= "A" && s <= "Z" || s >= "a" && s <= "z")
}
func substr(s string, start, length int) string {
if len(s) <= start {
return ""
}
end := start + length
if end > len(s) {
end = len(s)
}
return s[start:end]
}
func extractTechKeywords(text string) []string {
techPatterns := []string{"Go", "Golang", "Java", "Python", "JavaScript", "TypeScript", "React", "Vue", "MySQL", "PostgreSQL", "Redis", "MongoDB", "Kafka", "Docker", "Kubernetes", "微服务", "分布式"}
var result []string
for _, pattern := range techPatterns {
if strings.Contains(text, pattern) {
result = append(result, pattern)
}
}
return result
}
func parseTypes(typesStr string) []string {
if typesStr == "" {
return []string{"mcq", "fill", "sa", "algo"}
}
return strings.Split(typesStr, ",")
}
func getScoreByDifficulty(difficulty string) int {
switch difficulty {
case "入门":
return 5
case "初级":
return 10
case "中级":
return 15
case "进阶":
return 20
case "高级":
return 25
default:
return 10
}
}
+77
View File
@@ -0,0 +1,77 @@
package chat
import (
"context"
"fmt"
"resume-platform/internal/model"
"resume-platform/internal/prompt"
"resume-platform/internal/repository"
"resume-platform/internal/service/ai"
"github.com/google/uuid"
)
type ChatService interface {
Chat(ctx context.Context, userID string, question string) (string, error)
GetChatHistory(ctx context.Context, userID string) ([]*model.ChatHistory, error)
DeleteChatHistory(ctx context.Context, userID string) error
}
type chatService struct {
repo repository.ResumeRepository
ai *ai.AIService
}
func NewChatService(repo repository.ResumeRepository, ai *ai.AIService) ChatService {
return &chatService{repo: repo, ai: ai}
}
func (s *chatService) Chat(ctx context.Context, userID string, question string) (string, error) {
chunks, err := s.repo.GetDocumentChunksByUserID(userID)
if err != nil {
return "", err
}
if len(chunks) == 0 {
return s.ai.Generate(ctx, question)
}
var chunkList []model.ChunkWithScore
for _, chunk := range chunks {
chunkList = append(chunkList, model.ChunkWithScore{
Content: chunk.Content,
Metadata: chunk.Embedding,
})
}
var contextStr string
for i, chunk := range chunkList[:min(5, len(chunkList))] {
contextStr += fmt.Sprintf("[文档片段%d]\n%s\n\n", i+1, chunk.Content)
}
promptText := prompt.BuildDocumentAnalysisPrompt(contextStr, question)
answer, err := s.ai.Generate(ctx, promptText)
if err != nil {
return "", err
}
history := &model.ChatHistory{
ID: uuid.New().String(),
UserID: userID,
Question: question,
Answer: answer,
Context: contextStr,
}
s.repo.CreateChatHistory(history)
return answer, nil
}
func (s *chatService) GetChatHistory(ctx context.Context, userID string) ([]*model.ChatHistory, error) {
return s.repo.GetChatHistoryByUserID(userID)
}
func (s *chatService) DeleteChatHistory(ctx context.Context, userID string) error {
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package chat
import (
"context"
"resume-platform/internal/agent"
"resume-platform/internal/ai"
)
type IMService struct {
aiProvider ai.Provider
resumeAgent *agent.ResumeAgent
}
func NewIMService(provider ai.Provider, resumeAgent *agent.ResumeAgent) *IMService {
return &IMService{aiProvider: provider, resumeAgent: resumeAgent}
}
func (s *IMService) GetAIProvider() ai.Provider {
return s.aiProvider
}
func (s *IMService) GetResumeAgent() *agent.ResumeAgent {
return s.resumeAgent
}
func (s *IMService) GenerateWithAgent(ctx context.Context, query string) (string, error) {
if s.resumeAgent != nil {
return s.resumeAgent.Run(ctx, query)
}
if s.aiProvider != nil {
return s.aiProvider.Generate(ctx, query)
}
return "", nil
}
@@ -0,0 +1,165 @@
package document
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"resume-platform/internal/model"
"resume-platform/internal/parser"
"resume-platform/internal/repository"
"resume-platform/internal/service/knowledge"
"resume-platform/internal/service/resume"
"resume-platform/pkg/logger"
)
type DocumentService interface {
UploadDocument(ctx context.Context, userID string, fileName string, fileType string, fileSize int64, content string) (*model.Document, error)
GetDocuments(ctx context.Context, userID string) ([]*model.Document, error)
GetDocument(ctx context.Context, userID string, documentID string) (*model.Document, error)
DeleteDocument(ctx context.Context, userID string, documentID string) error
ProcessDocument(ctx context.Context, documentID string) error
ParseAndGenerateResume(ctx context.Context, userID string, fileName string, content []byte) (*model.Resume, error)
}
type documentService struct {
repo repository.ResumeRepository
resumeGenerator *resume.ResumeGeneratorService
knowledgeBaseService *knowledge.KnowledgeBaseService
}
func NewDocumentService(repo repository.ResumeRepository, resumeGenerator *resume.ResumeGeneratorService) DocumentService {
return &documentService{repo: repo, resumeGenerator: resumeGenerator}
}
func NewDocumentServiceWithKnowledge(repo repository.ResumeRepository, resumeGenerator *resume.ResumeGeneratorService, knowledgeBaseService *knowledge.KnowledgeBaseService) DocumentService {
return &documentService{repo: repo, resumeGenerator: resumeGenerator, knowledgeBaseService: knowledgeBaseService}
}
func (s *documentService) UploadDocument(ctx context.Context, userID string, fileName string, fileType string, fileSize int64, content string) (*model.Document, error) {
document := &model.Document{
ID: generateID(),
UserID: userID,
Name: fileName,
Type: fileType,
Size: fileSize,
Content: content,
Status: "uploading",
}
err := s.repo.CreateDocument(document)
if err != nil {
return nil, err
}
go s.ProcessDocument(ctx, document.ID)
return document, nil
}
func (s *documentService) GetDocuments(ctx context.Context, userID string) ([]*model.Document, error) {
return s.repo.GetDocumentsByUserID(userID)
}
func (s *documentService) GetDocument(ctx context.Context, userID string, documentID string) (*model.Document, error) {
return s.repo.GetDocument(documentID, userID)
}
func (s *documentService) DeleteDocument(ctx context.Context, userID string, documentID string) error {
return s.repo.DeleteDocument(documentID, userID)
}
func (s *documentService) ProcessDocument(ctx context.Context, documentID string) error {
document, err := s.repo.GetDocumentByID(documentID)
if err != nil {
return err
}
if document.Content == "" {
document.Status = "failed"
document.ErrorMsg = "document content is empty"
return s.repo.UpdateDocument(document)
}
chunks := s.splitText(document.Content, 512)
for i, chunk := range chunks {
metadata := map[string]interface{}{
"document_id": document.ID,
"chunk_index": i,
"user_id": document.UserID,
}
metadataJSON, _ := json.Marshal(metadata)
documentChunk := &model.DocumentChunk{
ID: generateID(),
DocumentID: document.ID,
ChunkIndex: i,
Content: chunk,
Metadata: string(metadataJSON),
}
err := s.repo.CreateDocumentChunk(documentChunk)
if err != nil {
document.Status = "failed"
document.ErrorMsg = err.Error()
s.repo.UpdateDocument(document)
return err
}
}
if s.knowledgeBaseService != nil {
err = s.knowledgeBaseService.AddDocument(ctx, document.ID, document.Content, document.UserID)
if err != nil {
logger.Warnf("Failed to add document to knowledge base: %v", err)
} else {
logger.Infof("Document %s added to knowledge base", document.ID)
}
}
document.Status = "processed"
return s.repo.UpdateDocument(document)
}
func (s *documentService) splitText(text string, chunkSize int) []string {
var chunks []string
words := []rune(text)
for i := 0; i < len(words); i += chunkSize {
end := i + chunkSize
if end > len(words) {
end = len(words)
}
chunks = append(chunks, string(words[i:end]))
}
return chunks
}
func generateID() string {
return uuid.New().String()
}
func (s *documentService) ParseAndGenerateResume(ctx context.Context, userID string, fileName string, content []byte) (*model.Resume, error) {
documentText, err := parser.ParseFile(fileName, bytes.NewReader(content))
if err != nil {
logger.Warnf("Failed to parse document %s: %v", fileName, err)
return nil, err
}
if documentText == "" {
return nil, fmt.Errorf("document content is empty after parsing")
}
resume, err := s.resumeGenerator.GenerateFromDocument(ctx, documentText)
if err != nil {
return nil, err
}
resume.UserID = userID
fmt.Printf("DEBUG document.go: resume.UserID set to: '%s'\n", resume.UserID)
return resume, nil
}
@@ -0,0 +1,132 @@
package knowledge
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/google/uuid"
"resume-platform/internal/ai"
"resume-platform/internal/model"
"resume-platform/internal/repository"
)
type EmbeddingService struct {
aiProvider ai.Provider
repo repository.ResumeRepository
}
func NewEmbeddingService(provider ai.Provider, repo repository.ResumeRepository) *EmbeddingService {
return &EmbeddingService{aiProvider: provider, repo: repo}
}
func (s *EmbeddingService) Generate(ctx context.Context, text string) ([]float64, error) {
if text == "" {
return nil, fmt.Errorf("empty text")
}
contentHash := s.hashContent(text)
cachedEmbedding, err := s.repo.GetEmbeddingByContentHash(contentHash)
if err == nil && cachedEmbedding != nil && cachedEmbedding.Embedding != "" {
var embedding []float64
if err := json.Unmarshal([]byte(cachedEmbedding.Embedding), &embedding); err == nil && len(embedding) > 0 {
return embedding, nil
}
}
prompt := fmt.Sprintf(`请将以下文本转换为向量嵌入。直接输出JSON数组,不要包含任何其他内容。
文本内容:
%s`, text)
result, err := s.aiProvider.Generate(ctx, prompt)
if err != nil {
return nil, err
}
result = cleanEmbeddingResult(result)
var embedding []float64
if err := json.Unmarshal([]byte(result), &embedding); err != nil {
return nil, fmt.Errorf("failed to parse embedding: %w", err)
}
return embedding, nil
}
func (s *EmbeddingService) GenerateAndStore(ctx context.Context, text, sourceType, sourceID, sourceName, userID string) ([]float64, error) {
embedding, err := s.Generate(ctx, text)
if err != nil {
return nil, err
}
contentHash := s.hashContent(text)
embeddingJSON, _ := json.Marshal(embedding)
err = s.repo.CreateEmbedding(&model.Embedding{
ID: uuid.New().String(),
ContentHash: contentHash,
Embedding: string(embeddingJSON),
SourceType: sourceType,
SourceID: sourceID,
SourceName: sourceName,
UserID: userID,
})
return embedding, err
}
func (s *EmbeddingService) BatchGenerate(ctx context.Context, texts []string) ([][]float64, error) {
var results [][]float64
for _, text := range texts {
embedding, err := s.Generate(ctx, text)
if err != nil {
return nil, err
}
results = append(results, embedding)
}
return results, nil
}
func (s *EmbeddingService) hashContent(content string) string {
h := sha256.New()
h.Write([]byte(content))
return hex.EncodeToString(h.Sum(nil))
}
func cleanEmbeddingResult(result string) string {
result = trimString(result)
if idx := findFirstIndex(result, '['); idx >= 0 {
result = result[idx:]
}
if idx := findLastIndex(result, ']'); idx >= 0 {
result = result[:idx+1]
}
return result
}
func trimString(s string) string {
return s
}
func findFirstIndex(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}
func findLastIndex(s string, c byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == c {
return i
}
}
return -1
}
@@ -0,0 +1,362 @@
package knowledge
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"resume-platform/internal/model"
"resume-platform/internal/repository"
"resume-platform/pkg/logger"
"strings"
)
type KnowledgeBaseService struct {
embeddingService *EmbeddingService
vectorService *VectorService
repo repository.ResumeRepository
}
func NewKnowledgeBaseService(embeddingService *EmbeddingService, vectorService *VectorService, repo repository.ResumeRepository) *KnowledgeBaseService {
return &KnowledgeBaseService{
embeddingService: embeddingService,
vectorService: vectorService,
repo: repo,
}
}
func (s *KnowledgeBaseService) AddDocument(ctx context.Context, documentID, content, userID string) error {
if content == "" {
return fmt.Errorf("empty content")
}
chunks := s.chunkContent(content, 500, 50)
doc, _ := s.repo.GetDocumentByID(documentID)
sourceName := documentID[:8]
if doc != nil && doc.Name != "" {
sourceName = doc.Name
}
for i, chunk := range chunks {
documentChunk := &model.DocumentChunk{
ID: uuid.New().String(),
DocumentID: documentID,
ChunkIndex: i,
Content: chunk,
Metadata: "",
}
err := s.vectorService.StoreChunk(ctx, documentChunk, userID, sourceName)
if err != nil {
return err
}
}
logger.Infof("Added %d chunks to document %s", len(chunks), documentID)
return nil
}
func (s *KnowledgeBaseService) AddResume(ctx context.Context, resumeID string, resume *model.Resume) error {
if resume == nil {
return fmt.Errorf("nil resume")
}
resumeText := s.serializeResume(resume)
if resumeText == "" {
return fmt.Errorf("empty resume content")
}
chunks := s.chunkContent(resumeText, 500, 50)
sourceName := resume.BasicInfo.Name
if sourceName == "" {
sourceName = resumeID[:8]
}
for i, chunk := range chunks {
documentChunk := &model.DocumentChunk{
ID: uuid.New().String(),
DocumentID: resumeID,
ChunkIndex: i,
Content: chunk,
Metadata: "",
}
err := s.vectorService.StoreChunk(ctx, documentChunk, resume.UserID, sourceName)
if err != nil {
return err
}
}
err := s.vectorService.Store(ctx, resumeText, "resume", resumeID, sourceName, resume.UserID)
if err != nil {
return err
}
logger.Infof("Added resume %s to knowledge base with %d chunks", resumeID, len(chunks))
return nil
}
func (s *KnowledgeBaseService) AddText(ctx context.Context, text, sourceType, sourceID, sourceName, userID string) error {
if text == "" {
return fmt.Errorf("empty text")
}
return s.vectorService.Store(ctx, text, sourceType, sourceID, sourceName, userID)
}
func (s *KnowledgeBaseService) Retrieve(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
return s.vectorService.SearchWithChunks(ctx, query, topK, userID)
}
func (s *KnowledgeBaseService) RetrieveAll(ctx context.Context, query string, topK int) ([]model.ChunkWithScore, error) {
return s.vectorService.Search(ctx, query, topK, "")
}
func (s *KnowledgeBaseService) DeleteDocument(ctx context.Context, documentID string) error {
err := s.repo.DeleteEmbeddingsBySource("document", documentID)
if err != nil {
return err
}
return s.vectorService.DeleteDocumentChunks(ctx, documentID)
}
func (s *KnowledgeBaseService) DeleteResume(ctx context.Context, resumeID string) error {
return s.repo.DeleteEmbeddingsBySource("resume", resumeID)
}
func (s *KnowledgeBaseService) GetStats(ctx context.Context, userID string) (map[string]int, error) {
var stats = map[string]int{}
if userID != "" {
embeddings, err := s.repo.GetEmbeddingsByUserID(userID)
if err != nil {
return nil, err
}
stats["embeddings"] = len(embeddings)
chunks, err := s.repo.GetDocumentChunksByUserID(userID)
if err != nil {
return nil, err
}
stats["chunks"] = len(chunks)
} else {
embeddings, err := s.repo.GetAllEmbeddings()
if err != nil {
return nil, err
}
stats["embeddings"] = len(embeddings)
chunks, err := s.repo.GetAllDocumentChunks()
if err != nil {
return nil, err
}
stats["chunks"] = len(chunks)
}
return stats, nil
}
func (s *KnowledgeBaseService) chunkContent(content string, chunkSize, overlap int) []string {
if content == "" {
return nil
}
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
var chunks []string
start := 0
contentLen := len(content)
for start < contentLen {
end := start + chunkSize
if end > contentLen {
end = contentLen
}
if end < contentLen {
for i := end; i > start && i > start+chunkSize-overlap; i-- {
c := rune(content[i])
if c == '\n' || c == ';' || c == '\r' {
end = i + 1
break
}
}
}
chunk := strings.TrimSpace(content[start:end])
if chunk != "" {
chunks = append(chunks, chunk)
}
start = end - overlap
if start < 0 {
start = 0
}
if start >= contentLen {
break
}
}
return chunks
}
func (s *KnowledgeBaseService) serializeResume(resume *model.Resume) string {
var builder strings.Builder
if resume.BasicInfo.Name != "" {
builder.WriteString("姓名:")
builder.WriteString(resume.BasicInfo.Name)
builder.WriteString("\n")
}
if resume.BasicInfo.Title != "" {
builder.WriteString("职位:")
builder.WriteString(resume.BasicInfo.Title)
builder.WriteString("\n")
}
if resume.BasicInfo.Email != "" {
builder.WriteString("邮箱:")
builder.WriteString(resume.BasicInfo.Email)
builder.WriteString("\n")
}
if resume.BasicInfo.Phone != "" {
builder.WriteString("电话:")
builder.WriteString(resume.BasicInfo.Phone)
builder.WriteString("\n")
}
if resume.BasicInfo.Location != "" {
builder.WriteString("所在地:")
builder.WriteString(resume.BasicInfo.Location)
builder.WriteString("\n")
}
if resume.BasicInfo.Summary != "" {
builder.WriteString("个人简介:")
builder.WriteString(resume.BasicInfo.Summary)
builder.WriteString("\n")
}
if resume.BasicInfo.JobTarget != "" {
builder.WriteString("求职目标:")
builder.WriteString(resume.BasicInfo.JobTarget)
builder.WriteString("\n")
}
if len(resume.Experience) > 0 {
builder.WriteString("\n【工作经历】\n")
for _, exp := range resume.Experience {
builder.WriteString("公司:")
builder.WriteString(exp.Company)
builder.WriteString("\n职位:")
builder.WriteString(exp.Position)
builder.WriteString("\n时间:")
builder.WriteString(exp.StartDate)
if exp.EndDate != "" {
builder.WriteString(" - ")
builder.WriteString(exp.EndDate)
}
builder.WriteString("\n职责:")
builder.WriteString(exp.Description)
builder.WriteString("\n")
if len(exp.Highlights) > 0 {
builder.WriteString("亮点:")
builder.WriteString(strings.Join(exp.Highlights, ""))
builder.WriteString("\n")
}
builder.WriteString("\n")
}
}
if len(resume.Education) > 0 {
builder.WriteString("\n【教育背景】\n")
for _, edu := range resume.Education {
builder.WriteString("学校:")
builder.WriteString(edu.School)
builder.WriteString("\n学位:")
builder.WriteString(edu.Degree)
builder.WriteString("\n专业:")
builder.WriteString(edu.Major)
builder.WriteString("\n时间:")
builder.WriteString(edu.StartDate)
if edu.EndDate != "" {
builder.WriteString(" - ")
builder.WriteString(edu.EndDate)
}
builder.WriteString("\n")
}
}
if len(resume.Skills) > 0 {
builder.WriteString("\n【专业技能】\n")
for _, skill := range resume.Skills {
builder.WriteString(skill.Name)
if skill.Level != "" {
builder.WriteString("")
builder.WriteString(skill.Level)
builder.WriteString("")
}
if skill.Category != "" {
builder.WriteString(" - ")
builder.WriteString(skill.Category)
}
builder.WriteString("\n")
}
}
if len(resume.Projects) > 0 {
builder.WriteString("\n【项目经验】\n")
for _, proj := range resume.Projects {
builder.WriteString("项目名称:")
builder.WriteString(proj.Name)
builder.WriteString("\n描述:")
builder.WriteString(proj.Description)
builder.WriteString("\n")
if len(proj.TechStack) > 0 {
builder.WriteString("技术栈:")
builder.WriteString(strings.Join(proj.TechStack, "、"))
builder.WriteString("\n")
}
if len(proj.Highlights) > 0 {
builder.WriteString("亮点:")
builder.WriteString(strings.Join(proj.Highlights, ""))
builder.WriteString("\n")
}
if len(proj.Achievements) > 0 {
builder.WriteString("成果:")
builder.WriteString(strings.Join(proj.Achievements, ""))
builder.WriteString("\n")
}
builder.WriteString("\n")
}
}
result := builder.String()
if len(result) > 15000 {
result = result[:15000]
}
return result
}
func (s *KnowledgeBaseService) BuildIndex(ctx context.Context) error {
documents, err := s.repo.GetAllDocumentChunks()
if err != nil {
return err
}
for _, chunk := range documents {
if chunk.Embedding == "" && chunk.Content != "" {
embedding, err := s.embeddingService.Generate(ctx, chunk.Content)
if err != nil {
return err
}
embeddingJSON, _ := json.Marshal(embedding)
chunk.Embedding = string(embeddingJSON)
err = s.repo.CreateDocumentChunk(chunk)
if err != nil {
return err
}
}
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package knowledge
import (
"context"
"fmt"
"resume-platform/internal/ai"
"resume-platform/internal/prompt"
"strings"
)
type RAGService struct {
aiProvider ai.Provider
knowledgeBaseService *KnowledgeBaseService
}
func NewRAGService(provider ai.Provider, knowledgeBaseService *KnowledgeBaseService) *RAGService {
return &RAGService{aiProvider: provider, knowledgeBaseService: knowledgeBaseService}
}
func (s *RAGService) Generate(ctx context.Context, query string, userID string) (string, error) {
contextResults, err := s.knowledgeBaseService.Retrieve(ctx, query, 5, userID)
if err != nil {
return "", err
}
var contextStr string
for _, result := range contextResults {
contextStr += fmt.Sprintf("【来源:%s】\n%s\n\n", result.SourceName, result.Content)
}
if contextStr == "" {
return s.aiProvider.Generate(ctx, query)
}
promptText := prompt.BuildRAGAnswerPrompt(contextStr, query)
return s.aiProvider.Generate(ctx, promptText)
}
func (s *RAGService) GenerateWithContext(ctx context.Context, query string, context []string) (string, error) {
contextStr := strings.Join(context, "\n\n")
promptText := prompt.BuildRAGAnswerPrompt(contextStr, query)
return s.aiProvider.Generate(ctx, promptText)
}
@@ -0,0 +1,201 @@
package knowledge
import (
"context"
"encoding/json"
"fmt"
"math"
"resume-platform/internal/model"
"resume-platform/internal/repository"
"sort"
)
type VectorService struct {
embeddingService *EmbeddingService
repo repository.ResumeRepository
}
func NewVectorService(embeddingService *EmbeddingService, repo repository.ResumeRepository) *VectorService {
return &VectorService{embeddingService: embeddingService, repo: repo}
}
func (s *VectorService) Store(ctx context.Context, content, sourceType, sourceID, sourceName, userID string) error {
_, err := s.embeddingService.GenerateAndStore(ctx, content, sourceType, sourceID, sourceName, userID)
return err
}
func (s *VectorService) StoreChunk(ctx context.Context, chunk *model.DocumentChunk, userID string, sourceName string) error {
if chunk.Content == "" {
return nil
}
embedding, err := s.embeddingService.Generate(ctx, chunk.Content)
if err != nil {
return err
}
embeddingJSON, _ := json.Marshal(embedding)
chunk.Embedding = string(embeddingJSON)
err = s.repo.CreateDocumentChunk(chunk)
if err != nil {
return err
}
contentHash := s.embeddingService.hashContent(chunk.Content)
err = s.repo.CreateEmbedding(&model.Embedding{
ID: chunk.ID,
ContentHash: contentHash,
Embedding: string(embeddingJSON),
SourceType: "document",
SourceID: chunk.DocumentID,
SourceName: sourceName,
UserID: userID,
})
return err
}
func (s *VectorService) Search(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
if query == "" {
return nil, fmt.Errorf("empty query")
}
queryEmbedding, err := s.embeddingService.Generate(ctx, query)
if err != nil {
return nil, err
}
var allEmbeddings []*model.Embedding
if userID != "" {
allEmbeddings, err = s.repo.GetEmbeddingsByUserID(userID)
} else {
allEmbeddings, err = s.repo.GetAllEmbeddings()
}
if err != nil {
return nil, err
}
var results []model.ChunkWithScore
for _, emb := range allEmbeddings {
if emb.Embedding == "" {
continue
}
var embedding []float64
if err := json.Unmarshal([]byte(emb.Embedding), &embedding); err != nil {
continue
}
score := cosineSimilarity(queryEmbedding, embedding)
if score > 0.3 {
results = append(results, model.ChunkWithScore{
Content: emb.SourceName,
Score: score,
SourceID: emb.SourceID,
SourceName: emb.SourceName,
SourceType: emb.SourceType,
})
}
}
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
if len(results) > topK {
results = results[:topK]
}
return results, nil
}
func (s *VectorService) SearchWithChunks(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
if query == "" {
return nil, fmt.Errorf("empty query")
}
queryEmbedding, err := s.embeddingService.Generate(ctx, query)
if err != nil {
return nil, err
}
var allChunks []*model.DocumentChunk
if userID != "" {
allChunks, err = s.repo.GetDocumentChunksByUserID(userID)
} else {
allChunks, err = s.repo.GetAllDocumentChunks()
}
if err != nil {
return nil, err
}
var results []model.ChunkWithScore
for _, chunk := range allChunks {
if chunk.Embedding == "" || chunk.Content == "" {
continue
}
var embedding []float64
if err := json.Unmarshal([]byte(chunk.Embedding), &embedding); err != nil {
continue
}
score := cosineSimilarity(queryEmbedding, embedding)
if score > 0.3 {
doc, _ := s.repo.GetDocumentByID(chunk.DocumentID)
sourceName := ""
if doc != nil {
sourceName = doc.Name
}
results = append(results, model.ChunkWithScore{
Content: chunk.Content,
Score: score,
SourceID: chunk.DocumentID,
SourceName: sourceName,
SourceType: "document",
})
}
}
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
if len(results) > topK {
results = results[:topK]
}
return results, nil
}
func (s *VectorService) Delete(ctx context.Context, sourceType, sourceID string) error {
return s.repo.DeleteEmbeddingsBySource(sourceType, sourceID)
}
func (s *VectorService) DeleteDocumentChunks(ctx context.Context, documentID string) error {
_, err := s.repo.GetDocumentChunksByDocumentID(documentID)
if err != nil {
return err
}
return nil
}
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) {
return 0
}
var dotProduct, magA, magB float64
for i := range a {
dotProduct += a[i] * b[i]
magA += a[i] * a[i]
magB += b[i] * b[i]
}
if magA == 0 || magB == 0 {
return 0
}
return dotProduct / (math.Sqrt(magA) * math.Sqrt(magB))
}
@@ -0,0 +1,52 @@
package portfolio
import (
"resume-platform/internal/model"
"resume-platform/internal/repository"
)
type PortfolioService interface {
GetPortfolioItems() ([]*model.PortfolioItem, error)
GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error)
GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error)
GetPortfolioItem(id uint) (*model.PortfolioItem, error)
CreatePortfolioItem(item *model.PortfolioItem) error
UpdatePortfolioItem(id uint, item *model.PortfolioItem) error
DeletePortfolioItem(id uint) error
}
type portfolioService struct {
repo repository.ResumeRepository
}
func NewPortfolioService(repo repository.ResumeRepository) PortfolioService {
return &portfolioService{repo: repo}
}
func (s *portfolioService) GetPortfolioItems() ([]*model.PortfolioItem, error) {
return s.repo.GetPortfolioItems()
}
func (s *portfolioService) GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error) {
return s.repo.GetPortfolioItemsWithPagination(page, pageSize)
}
func (s *portfolioService) GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error) {
return s.repo.GetPortfolioItemsByUserID(userID)
}
func (s *portfolioService) GetPortfolioItem(id uint) (*model.PortfolioItem, error) {
return s.repo.GetPortfolioItem(id)
}
func (s *portfolioService) CreatePortfolioItem(item *model.PortfolioItem) error {
return s.repo.CreatePortfolioItem(item)
}
func (s *portfolioService) UpdatePortfolioItem(id uint, item *model.PortfolioItem) error {
return s.repo.UpdatePortfolioItem(id, item)
}
func (s *portfolioService) DeletePortfolioItem(id uint) error {
return s.repo.DeletePortfolioItem(id)
}
+65
View File
@@ -0,0 +1,65 @@
package quiz
import (
"resume-platform/internal/model"
"resume-platform/internal/repository"
"github.com/google/uuid"
)
type QuizService interface {
CreateQuizRecord(record *model.QuizRecord) error
GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error)
GetQuizRecord(id string) (*model.QuizRecord, error)
DeleteQuizRecord(id string) error
CreateFavoriteQuestion(fav *model.FavoriteQuestion) error
GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error)
DeleteFavoriteQuestion(id string) error
ToggleFavorite(id string) error
}
type quizService struct {
repo repository.ResumeRepository
}
func NewQuizService(repo repository.ResumeRepository) QuizService {
return &quizService{repo: repo}
}
func (s *quizService) CreateQuizRecord(record *model.QuizRecord) error {
if record.ID == "" {
record.ID = uuid.New().String()
}
return s.repo.CreateQuizRecord(record)
}
func (s *quizService) GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error) {
return s.repo.GetQuizRecordsByResume(resumeRoute)
}
func (s *quizService) GetQuizRecord(id string) (*model.QuizRecord, error) {
return s.repo.GetQuizRecord(id)
}
func (s *quizService) DeleteQuizRecord(id string) error {
return s.repo.DeleteQuizRecord(id)
}
func (s *quizService) CreateFavoriteQuestion(fav *model.FavoriteQuestion) error {
if fav.ID == "" {
fav.ID = uuid.New().String()
}
return s.repo.CreateFavoriteQuestion(fav)
}
func (s *quizService) GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error) {
return s.repo.GetFavoriteQuestions(resumeRoute, isFavorite, isCorrect)
}
func (s *quizService) DeleteFavoriteQuestion(id string) error {
return s.repo.DeleteFavoriteQuestion(id)
}
func (s *quizService) ToggleFavorite(id string) error {
return s.repo.ToggleFavorite(id)
}
+257
View File
@@ -0,0 +1,257 @@
package resume
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"resume-platform/internal/ai"
"resume-platform/internal/model"
"resume-platform/internal/prompt"
"resume-platform/pkg/logger"
"resume-platform/pkg/utils"
"strings"
)
type ResumeGeneratorService struct {
aiProvider ai.Provider
}
func NewResumeGeneratorService(provider ai.Provider) *ResumeGeneratorService {
return &ResumeGeneratorService{aiProvider: provider}
}
func (s *ResumeGeneratorService) GenerateFromDocument(ctx context.Context, documentContent string) (*model.Resume, error) {
if s.aiProvider == nil {
return nil, fmt.Errorf("AI provider not configured")
}
cleanedContent := cleanDocumentContent(documentContent)
if cleanedContent == "" {
return nil, fmt.Errorf("document content is empty after cleaning")
}
maxContentLength := 15000
if len(cleanedContent) > maxContentLength {
cleanedContent = truncateContent(cleanedContent, maxContentLength)
}
return s.extractAllSections(ctx, cleanedContent)
}
func (s *ResumeGeneratorService) extractAllSections(ctx context.Context, content string) (*model.Resume, error) {
promptText := prompt.BuildResumeExtractorPrompt(content)
result, err := s.aiProvider.Generate(ctx, promptText)
if err != nil {
return nil, err
}
cleanedResult := utils.CleanAIResponse(result)
var rawData struct {
BasicInfo struct {
Name string `json:"name"`
Title string `json:"title"`
Email string `json:"email"`
Phone string `json:"phone"`
Location string `json:"location"`
Website string `json:"website"`
Summary string `json:"summary"`
ExperienceYears interface{} `json:"experience_years"`
JobTarget string `json:"job_target"`
Industry string `json:"industry"`
CoreAdvantages []interface{} `json:"core_advantages"`
} `json:"basic_info"`
Experience []struct {
Company string `json:"company"`
Position string `json:"position"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Description string `json:"description"`
Highlights []interface{} `json:"highlights"`
Platforms []interface{} `json:"platforms"`
} `json:"experience"`
Education []struct {
School string `json:"school"`
Degree string `json:"degree"`
Major string `json:"major"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
GPA string `json:"gpa"`
} `json:"education"`
Skills []struct {
Name string `json:"name"`
Level string `json:"level"`
Category string `json:"category"`
} `json:"skills"`
Projects []struct {
Name string `json:"name"`
Description string `json:"description"`
TechStack []interface{} `json:"tech_stack"`
URL string `json:"url"`
Highlights []interface{} `json:"highlights"`
Achievements []interface{} `json:"achievements"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
} `json:"projects"`
}
if err := json.Unmarshal([]byte(cleanedResult), &rawData); err != nil {
logger.Errorf("Failed to parse AI result: %v, raw result: %s", err, cleanedResult)
return nil, fmt.Errorf("failed to parse AI result: %w", err)
}
coreAdvantages := make([]string, 0, len(rawData.BasicInfo.CoreAdvantages))
for _, item := range rawData.BasicInfo.CoreAdvantages {
coreAdvantages = append(coreAdvantages, utils.ConvertToString(item))
}
experience := make([]model.Experience, 0, len(rawData.Experience))
for _, exp := range rawData.Experience {
if exp.Company == "" && exp.Position == "" {
continue
}
highlights := make([]string, 0, len(exp.Highlights))
for _, item := range exp.Highlights {
highlights = append(highlights, utils.ConvertToString(item))
}
platforms := make([]string, 0, len(exp.Platforms))
for _, item := range exp.Platforms {
platforms = append(platforms, utils.ConvertToString(item))
}
experience = append(experience, model.Experience{
Company: exp.Company,
Position: exp.Position,
StartDate: exp.StartDate,
EndDate: exp.EndDate,
Description: exp.Description,
Highlights: highlights,
Platforms: platforms,
})
}
education := make([]model.Education, 0, len(rawData.Education))
for _, edu := range rawData.Education {
if edu.School == "" {
continue
}
education = append(education, model.Education{
School: edu.School,
Degree: edu.Degree,
Major: edu.Major,
StartDate: edu.StartDate,
EndDate: edu.EndDate,
GPA: edu.GPA,
})
}
skills := make([]model.Skill, 0, len(rawData.Skills))
for _, skill := range rawData.Skills {
if skill.Name == "" {
continue
}
skills = append(skills, model.Skill{
Name: skill.Name,
Level: skill.Level,
Category: skill.Category,
})
}
projects := make([]model.Project, 0, len(rawData.Projects))
for _, proj := range rawData.Projects {
if proj.Name == "" && proj.Description == "" {
continue
}
techStack := make([]string, 0, len(proj.TechStack))
for _, item := range proj.TechStack {
techStack = append(techStack, utils.ConvertToString(item))
}
highlights := make([]string, 0, len(proj.Highlights))
for _, item := range proj.Highlights {
highlights = append(highlights, utils.ConvertToString(item))
}
achievements := make([]string, 0, len(proj.Achievements))
for _, item := range proj.Achievements {
achievements = append(achievements, utils.ConvertToString(item))
}
projects = append(projects, model.Project{
Name: proj.Name,
Description: proj.Description,
TechStack: techStack,
URL: proj.URL,
Highlights: highlights,
Achievements: achievements,
StartDate: proj.StartDate,
EndDate: proj.EndDate,
ShowInResume: false,
})
}
resume := &model.Resume{
ID: uuid.New().String(),
Route: generateRouteFromName(rawData.BasicInfo.Name),
BasicInfo: model.BasicInfo{
Name: rawData.BasicInfo.Name,
Title: rawData.BasicInfo.Title,
Email: rawData.BasicInfo.Email,
Phone: rawData.BasicInfo.Phone,
Location: rawData.BasicInfo.Location,
Website: rawData.BasicInfo.Website,
Summary: rawData.BasicInfo.Summary,
ExperienceYears: utils.ConvertToString(rawData.BasicInfo.ExperienceYears),
JobTarget: rawData.BasicInfo.JobTarget,
Industry: rawData.BasicInfo.Industry,
CoreAdvantages: coreAdvantages,
},
Experience: experience,
Education: education,
Skills: skills,
Projects: projects,
Template: "modern",
ShowInHome: false,
}
return resume, nil
}
func cleanDocumentContent(content string) string {
return utils.CleanText(content)
}
func generateRouteFromName(name string) string {
if name == "" {
return uuid.New().String()[:8]
}
route := ""
for _, c := range name {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
route += string(c)
}
}
if route == "" {
return uuid.New().String()[:8]
}
return strings.ToLower(route)
}
func truncateContent(content string, maxLength int) string {
if len(content) <= maxLength {
return content
}
truncated := content[:maxLength]
for i := len(truncated) - 1; i >= 0; i-- {
c := rune(truncated[i])
if c == '。' || c == '' || c == '' || c == '' ||
c == '.' || c == '!' || c == '?' || c == ';' ||
c == '\n' || c == '\r' {
truncated = truncated[:i+1]
break
}
}
return truncated + "\n\n[内容已截断,仅保留关键信息]"
}
+222
View File
@@ -0,0 +1,222 @@
package resume
import (
"errors"
"regexp"
"resume-platform/internal/model"
"resume-platform/internal/repository"
"github.com/google/uuid"
)
type ResumeService interface {
GetResume(id string) (*model.Resume, error)
GetResumeByRoute(route string) (*model.Resume, error)
RouteExists(route string) bool
GetAllResumes() ([]*model.Resume, error)
GetResumesByUserID(userID string) ([]*model.Resume, error)
GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error)
GetResumeWithMask(id string) (*model.Resume, error)
CreateResume(resume *model.Resume) (*model.Resume, error)
CreateResumeWithRoute(route string, resume *model.Resume) (*model.Resume, error)
UpdateResume(id string, update *model.ResumeUpdate) (*model.Resume, error)
DeleteResume(id string) error
UpdateResumeUserID(resumeID, userID string) error
InitDefaultResume() (*model.Resume, error)
}
type resumeService struct {
repo repository.ResumeRepository
}
func NewResumeService(repo repository.ResumeRepository) ResumeService {
return &resumeService{repo: repo}
}
func (s *resumeService) GetResume(id string) (*model.Resume, error) {
return s.repo.GetResume(id)
}
func (s *resumeService) GetResumeByRoute(route string) (*model.Resume, error) {
return s.repo.GetResumeByRoute(route)
}
func (s *resumeService) RouteExists(route string) bool {
return s.repo.RouteExists(route)
}
func (s *resumeService) GetAllResumes() ([]*model.Resume, error) {
return s.repo.GetAllResumes()
}
func (s *resumeService) GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error) {
return s.repo.GetResumesWithPagination(page, pageSize)
}
func (s *resumeService) CreateResume(resume *model.Resume) (*model.Resume, error) {
if resume.ID == "" {
resume.ID = uuid.New().String()
}
if resume.Template == "" {
resume.Template = "modern"
}
err := s.repo.CreateResume(resume)
if err != nil {
return nil, err
}
return s.repo.GetResume(resume.ID)
}
func (s *resumeService) CreateResumeWithRoute(route string, resume *model.Resume) (*model.Resume, error) {
if s.repo.RouteExists(route) {
return nil, errors.New("route already exists")
}
if resume.ID == "" {
resume.ID = uuid.New().String()
}
if resume.Template == "" {
resume.Template = "modern"
}
resume.Route = route
err := s.repo.CreateResume(resume)
if err != nil {
return nil, err
}
return s.repo.GetResume(resume.ID)
}
func (s *resumeService) GetResumesByUserID(userID string) ([]*model.Resume, error) {
return s.repo.GetResumesByUserID(userID)
}
func (s *resumeService) UpdateResume(id string, update *model.ResumeUpdate) (*model.Resume, error) {
if !s.repo.Exists(id) {
return nil, errors.New("resume not found")
}
err := s.repo.UpdateResume(id, update)
if err != nil {
return nil, err
}
return s.repo.GetResume(id)
}
func (s *resumeService) DeleteResume(id string) error {
if !s.repo.Exists(id) {
return errors.New("resume not found")
}
return s.repo.DeleteResume(id)
}
func (s *resumeService) UpdateResumeUserID(resumeID, userID string) error {
if !s.repo.Exists(resumeID) {
return errors.New("resume not found")
}
return s.repo.UpdateResumeUserID(resumeID, userID)
}
func (s *resumeService) InitDefaultResume() (*model.Resume, error) {
defaultID := "default"
if s.repo.Exists(defaultID) {
return s.repo.GetResume(defaultID)
}
defaultResume := &model.Resume{
ID: defaultID,
Route: defaultID,
Template: "modern",
BasicInfo: model.BasicInfo{
Name: "张三",
Title: "全栈开发工程师",
Email: "zhangsan@example.com",
Phone: "13800138000",
Location: "北京市",
Website: "https://zhangsan.dev",
Summary: "5年以上软件开发经验,专注于Web全栈开发,熟悉Go、React、Vue等技术栈。热爱技术,善于解决复杂问题。",
Avatar: "",
},
Experience: []model.Experience{
{
Company: "某知名互联网公司",
Position: "高级开发工程师",
StartDate: "2022-01",
EndDate: "至今",
Description: "负责核心业务系统的架构设计与开发",
Highlights: []string{"主导微服务架构改造", "优化系统性能提升50%", "带领5人团队完成多个重要项目"},
},
{
Company: "创业公司",
Position: "技术负责人",
StartDate: "2019-06",
EndDate: "2021-12",
Description: "从0到1搭建技术团队和产品",
Highlights: []string{"建立完整技术体系", "打造百万级用户产品", "获得天使轮融资"},
},
},
Education: []model.Education{
{
School: "清华大学",
Degree: "本科",
Major: "计算机科学与技术",
StartDate: "2015-09",
EndDate: "2019-06",
GPA: "3.8/4.0",
},
},
Skills: []model.Skill{
{Name: "Go", Level: "expert", Category: "后端"},
{Name: "Python", Level: "advanced", Category: "后端"},
{Name: "React", Level: "advanced", Category: "前端"},
{Name: "Vue", Level: "advanced", Category: "前端"},
{Name: "MySQL", Level: "advanced", Category: "数据库"},
{Name: "Redis", Level: "intermediate", Category: "数据库"},
{Name: "Docker", Level: "advanced", Category: "运维"},
{Name: "Kubernetes", Level: "intermediate", Category: "运维"},
},
Projects: []model.Project{
{
Name: "电商平台",
Description: "基于微服务架构的B2C电商平台",
TechStack: []string{"Go", "React", "MySQL", "Redis", "Kubernetes"},
URL: "https://github.com/example/ecommerce",
Highlights: []string{"日订单量突破10万", "系统可用性99.9%", "支持百万级并发"},
},
{
Name: "任务管理系统",
Description: "团队协作任务管理工具",
TechStack: []string{"Vue", "Node.js", "MongoDB"},
URL: "https://github.com/example/taskmanager",
Highlights: []string{"500+企业客户", "月活跃用户10万"},
},
},
}
err := s.repo.CreateResume(defaultResume)
if err != nil {
return nil, err
}
return defaultResume, nil
}
func maskPhone(phone string) string {
re := regexp.MustCompile(`(\d{3})\d{4}(\d{4})`)
return re.ReplaceAllString(phone, "$1****$2")
}
func maskEmail(email string) string {
re := regexp.MustCompile(`(\w{1,3})[\w.]*@`)
return re.ReplaceAllString(email, "$1***@")
}
func (s *resumeService) GetResumeWithMask(id string) (*model.Resume, error) {
resume, err := s.repo.GetResume(id)
if err != nil {
return nil, err
}
resume.BasicInfo.Phone = maskPhone(resume.BasicInfo.Phone)
resume.BasicInfo.Email = maskEmail(resume.BasicInfo.Email)
return resume, nil
}
+85
View File
@@ -0,0 +1,85 @@
package system
import (
"resume-platform/internal/model"
"resume-platform/internal/repository"
"github.com/google/uuid"
)
type SystemService interface {
GetAllMenus() ([]*model.Menu, error)
CreateMenu(menu *model.Menu) error
UpdateMenu(id string, menu *model.Menu) error
DeleteMenu(id string) error
GetSystemConfig() (*model.SystemConfig, error)
CreateSystemConfig(config *model.SystemConfig) error
UpdateSystemConfig(id string, config *model.SystemConfig) error
InitDefaultMenus() error
}
type systemService struct {
repo repository.ResumeRepository
}
func NewSystemService(repo repository.ResumeRepository) SystemService {
return &systemService{repo: repo}
}
func (s *systemService) GetAllMenus() ([]*model.Menu, error) {
return s.repo.GetAllMenus()
}
func (s *systemService) CreateMenu(menu *model.Menu) error {
if menu.ID == "" {
menu.ID = uuid.New().String()
}
return s.repo.CreateMenu(menu)
}
func (s *systemService) UpdateMenu(id string, menu *model.Menu) error {
return s.repo.UpdateMenu(id, menu)
}
func (s *systemService) DeleteMenu(id string) error {
return s.repo.DeleteMenu(id)
}
func (s *systemService) GetSystemConfig() (*model.SystemConfig, error) {
return s.repo.GetSystemConfig()
}
func (s *systemService) CreateSystemConfig(config *model.SystemConfig) error {
if config.ID == "" {
config.ID = "default"
}
return s.repo.CreateSystemConfig(config)
}
func (s *systemService) UpdateSystemConfig(id string, config *model.SystemConfig) error {
return s.repo.UpdateSystemConfig(id, config)
}
func (s *systemService) InitDefaultMenus() error {
menus, err := s.repo.GetAllMenus()
if err != nil {
return err
}
if len(menus) > 0 {
return nil
}
defaultMenus := []*model.Menu{
{ID: "menu-user", Name: "人员管理", Icon: "fas fa-users", Path: "user", ParentID: "", SortOrder: 1, IsFixed: true, IsEnabled: true},
{ID: "menu-menu", Name: "菜单管理", Icon: "fas fa-list", Path: "menu", ParentID: "", SortOrder: 2, IsFixed: true, IsEnabled: true},
{ID: "menu-settings", Name: "系统设置", Icon: "fas fa-cog", Path: "settings", ParentID: "", SortOrder: 3, IsFixed: true, IsEnabled: true},
}
for _, menu := range defaultMenus {
if err := s.repo.CreateMenu(menu); err != nil {
return err
}
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package user
import (
"resume-platform/internal/model"
"resume-platform/internal/repository"
"github.com/google/uuid"
)
type UserService interface {
CreateUser(user *model.User) error
GetUserByUsername(username string) (*model.User, error)
GetUserByID(id string) (*model.User, error)
GetAllUsers() ([]*model.User, error)
GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error)
UpdateUser(id string, user *model.User) error
DeleteUser(id string) error
}
type userService struct {
repo repository.ResumeRepository
}
func NewUserService(repo repository.ResumeRepository) UserService {
return &userService{repo: repo}
}
func (s *userService) CreateUser(user *model.User) error {
if user.ID == "" {
user.ID = uuid.New().String()
}
return s.repo.CreateUser(user)
}
func (s *userService) GetUserByUsername(username string) (*model.User, error) {
return s.repo.GetUserByUsername(username)
}
func (s *userService) GetUserByID(id string) (*model.User, error) {
return s.repo.GetUserByID(id)
}
func (s *userService) GetAllUsers() ([]*model.User, error) {
return s.repo.GetAllUsers()
}
func (s *userService) GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error) {
return s.repo.GetUsersWithPagination(page, pageSize)
}
func (s *userService) UpdateUser(id string, user *model.User) error {
return s.repo.UpdateUser(id, user)
}
func (s *userService) DeleteUser(id string) error {
return s.repo.DeleteUser(id)
}
+220
View File
@@ -0,0 +1,220 @@
// Package config 配置加载模块,基于 Viper 读取 YAML 配置并提供默认值
package config
import (
"errors"
"strings"
"github.com/spf13/viper"
)
const (
DefaultAdminPath = "/admin" // 默认后台管理路径
DefaultMaxLoginAttempts = 5 // 默认最大登录尝试次数
DefaultLockoutDuration = 15 // 默认锁定时长(分钟)
DefaultServerHost = "0.0.0.0" // 默认服务监听地址
DefaultServerPort = 8090 // 默认服务端口
DefaultServerMode = "release" // 默认运行模式
DefaultDatabaseType = "sqlite" // 默认数据库类型
DefaultDatabasePath = "./data/resume.db" // 默认数据库文件路径
DefaultLogLevel = "info" // 默认日志级别
DefaultTemplateDefault = "modern" // 默认简历模板
)
// Config 全局配置结构体
type Config struct {
Server ServerConfig `mapstructure:"server"` // 服务配置
Database DatabaseConfig `mapstructure:"database"` // 数据库配置
Auth AuthConfig `mapstructure:"auth"` // 认证配置
Template TemplateConfig `mapstructure:"template"` // 模板配置
Log LogConfig `mapstructure:"log"` // 日志配置
AI AIConfig `mapstructure:"ai"` // AI配置
}
// ServerConfig 服务配置
type ServerConfig struct {
Host string `mapstructure:"host"` // 监听地址
Port int `mapstructure:"port"` // 监听端口
Mode string `mapstructure:"mode"` // 运行模式(debug/release
Debug bool `mapstructure:"debug"` // 是否开启调试
}
// DatabaseConfig 数据库配置
type DatabaseConfig struct {
Type string `mapstructure:"type"` // 数据库类型
Path string `mapstructure:"path"` // 数据库文件路径
Password string `mapstructure:"password"` // 数据库加密密码
}
// AuthConfig 认证配置
type AuthConfig struct {
Enabled bool `mapstructure:"enabled"` // 是否启用认证
Username string `mapstructure:"username"` // 管理员用户名
Password string `mapstructure:"password"` // 管理员密码
AdminPath string `mapstructure:"admin_path"` // 后台路径
MaxLoginAttempts int `mapstructure:"max_login_attempts"` // 最大登录尝试
LockoutDuration int `mapstructure:"lockout_duration"` // 锁定时长(分钟)
}
// TemplateConfig 模板配置
type TemplateConfig struct {
Default string `mapstructure:"default"` // 默认模板名称
}
// LogConfig 日志配置
type LogConfig struct {
Level string `mapstructure:"level"` // 日志级别
Output string `mapstructure:"output"` // 输出文件路径
}
// AIConfig AI 大模型配置
type AIConfig struct {
Provider string `mapstructure:"provider"` // 默认供应商
Enabled bool `mapstructure:"enabled"` // 是否启用
Spark SparkConfig `mapstructure:"spark"` // 讯飞星火配置
Tongyi TongyiConfig `mapstructure:"tongyi"` // 通义千问配置
Doubao DoubaoConfig `mapstructure:"doubao"` // 豆包配置
DeepSeek DeepSeekConfig `mapstructure:"deepseek"` // DeepSeek配置
}
// SparkConfig 讯飞星火配置
type SparkConfig struct {
APPID string `mapstructure:"app_id"` // APP ID
APIKey string `mapstructure:"api_key"` // API Key
APISecret string `mapstructure:"api_secret"` // API Secret
APIPassword string `mapstructure:"api_password"` // API 密码(HTTP接口使用)
BaseURL string `mapstructure:"base_url"` // 接口地址
Model string `mapstructure:"model"` // 模型版本
MaxTokens int `mapstructure:"max_tokens"` // 最大token数
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
WebSearch bool `mapstructure:"web_search"` // 是否启用联网搜索
ShowRefLabel bool `mapstructure:"show_ref_label"` // 是否显示引用标签
}
// TongyiConfig 通义千问配置
type TongyiConfig struct {
APIKey string `mapstructure:"api_key"` // API Key
BaseURL string `mapstructure:"base_url"` // 接口地址
Model string `mapstructure:"model"` // 模型名称
MaxTokens int `mapstructure:"max_tokens"` // 最大token数
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
}
// DoubaoConfig 豆包配置
type DoubaoConfig struct {
APIKey string `mapstructure:"api_key"` // API Key
BaseURL string `mapstructure:"base_url"` // 接口地址
Model string `mapstructure:"model"` // 模型名称
MaxTokens int `mapstructure:"max_tokens"` // 最大token数
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
}
// DeepSeekConfig DeepSeek 配置
type DeepSeekConfig struct {
APIKey string `mapstructure:"api_key"` // API Key
BaseURL string `mapstructure:"base_url"` // 接口地址
Model string `mapstructure:"model"` // 模型名称
MaxTokens int `mapstructure:"max_tokens"` // 最大token数
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
}
// setDefaults 设置 Viper 默认值
// @author sunct
func setDefaults() {
viper.SetDefault("server.host", DefaultServerHost)
viper.SetDefault("server.port", DefaultServerPort)
viper.SetDefault("server.mode", DefaultServerMode)
viper.SetDefault("server.debug", false)
viper.SetDefault("database.type", DefaultDatabaseType)
viper.SetDefault("database.path", DefaultDatabasePath)
viper.SetDefault("auth.enabled", true)
viper.SetDefault("auth.admin_path", DefaultAdminPath)
viper.SetDefault("auth.max_login_attempts", DefaultMaxLoginAttempts)
viper.SetDefault("auth.lockout_duration", DefaultLockoutDuration)
viper.SetDefault("template.default", DefaultTemplateDefault)
viper.SetDefault("log.level", DefaultLogLevel)
}
// LoadConfig 加载并解析配置文件,支持多路径查找
// @return *Config 配置实例
// @return error 加载错误
// @author sunct
func LoadConfig() (*Config, error) {
setDefaults()
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./config")
viper.AddConfigPath(".")
viper.AddConfigPath("../config")
viper.AddConfigPath("../")
if err := viper.ReadInConfig(); err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if !errors.As(err, &configFileNotFoundError) {
return nil, err
}
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, err
}
if err := validateAndNormalize(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// validateAndNormalize 校验并规范化配置,补全默认值
// @param cfg 配置实例
// @return error 校验错误
// @author sunct
func validateAndNormalize(cfg *Config) error {
if cfg.Server.Port <= 0 || cfg.Server.Port > 65535 {
cfg.Server.Port = DefaultServerPort
}
if cfg.Server.Host == "" {
cfg.Server.Host = DefaultServerHost
}
if cfg.Server.Mode == "" {
cfg.Server.Mode = DefaultServerMode
}
if cfg.Database.Type == "" {
cfg.Database.Type = DefaultDatabaseType
}
if cfg.Database.Path == "" {
cfg.Database.Path = DefaultDatabasePath
}
if !strings.HasPrefix(cfg.Auth.AdminPath, "/") {
cfg.Auth.AdminPath = "/" + cfg.Auth.AdminPath
}
if cfg.Auth.AdminPath == "/" {
cfg.Auth.AdminPath = DefaultAdminPath
}
if cfg.Auth.MaxLoginAttempts <= 0 {
cfg.Auth.MaxLoginAttempts = DefaultMaxLoginAttempts
}
if cfg.Auth.LockoutDuration <= 0 {
cfg.Auth.LockoutDuration = DefaultLockoutDuration
}
if cfg.Template.Default == "" {
cfg.Template.Default = DefaultTemplateDefault
}
if cfg.Log.Level == "" {
cfg.Log.Level = DefaultLogLevel
}
return nil
}
+8
View File
@@ -0,0 +1,8 @@
package constant
const (
RequestIdKey = "request-id"
AppNameKey = "app-name"
RequestRouteKey = "request-route"
ClientIP = "client-ip"
)
+76
View File
@@ -0,0 +1,76 @@
package idgen
import (
"crypto/rand"
"fmt"
"sync"
"time"
)
var (
generator *FlakeIDGenerator
once sync.Once
)
type FlakeIDGenerator struct {
mu sync.Mutex
lastTs int64
seq int64
nodeID int64
}
func NewFlakeIDGenerator(nodeID int64) *FlakeIDGenerator {
return &FlakeIDGenerator{
nodeID: nodeID,
}
}
func (g *FlakeIDGenerator) GetID() (string, error) {
g.mu.Lock()
defer g.mu.Unlock()
now := time.Now().UnixNano() / 1e6
if now < g.lastTs {
return "", fmt.Errorf("clock moved backwards")
}
if now == g.lastTs {
g.seq++
if g.seq >= 1024 {
for now <= g.lastTs {
now = time.Now().UnixNano() / 1e6
}
g.seq = 0
}
} else {
g.seq = 0
}
g.lastTs = now
id := (now << 22) | (g.nodeID << 10) | g.seq
return fmt.Sprintf("%d", id), nil
}
func GetID() (string, error) {
once.Do(func() {
nodeID := int64(1)
b := make([]byte, 2)
_, err := rand.Read(b)
if err == nil {
nodeID = int64(b[0])<<8 | int64(b[1])
nodeID &= 0x3FF
}
generator = NewFlakeIDGenerator(nodeID)
})
return generator.GetID()
}
func MustGetID() string {
id, err := GetID()
if err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return id
}
+193
View File
@@ -0,0 +1,193 @@
// Package logger 日志模块,基于 logrus 封装统一的日志接口
package logger
import (
"context"
"io"
"os"
"path/filepath"
"resume-platform/pkg/constant"
"github.com/sirupsen/logrus"
)
var (
defaultLogger *logrus.Logger // 默认日志实例
)
// Config 日志配置
type Config struct {
Level string // 日志级别
Output string // 输出文件路径
}
// Init 初始化日志系统
// @param cfg 日志配置
// @return error 初始化错误
// @author sunct
func Init(cfg Config) error {
l := logrus.New()
level, err := logrus.ParseLevel(cfg.Level)
if err != nil {
level = logrus.InfoLevel
}
l.SetLevel(level)
l.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006-01-02 15:04:05",
})
if cfg.Output != "" {
logDir := filepath.Dir(cfg.Output)
if logDir != "." && logDir != "" {
if err := os.MkdirAll(logDir, 0755); err != nil {
return err
}
}
file, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
mw := io.MultiWriter(os.Stdout, file)
l.SetOutput(mw)
} else {
l.SetOutput(os.Stdout)
}
defaultLogger = l
return nil
}
// Get 获取默认日志实例
// @return *logrus.Logger 日志实例
// @author sunct
func Get() *logrus.Logger {
if defaultLogger == nil {
defaultLogger = logrus.New()
defaultLogger.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006-01-02 15:04:05",
})
}
return defaultLogger
}
// Debug 输出 Debug 级别日志
// @param args 日志内容
func Debug(args ...interface{}) { Get().Debug(args...) }
// Debugf 格式化输出 Debug 级别日志
// @param format 格式字符串
// @param args 格式化参数
func Debugf(format string, args ...interface{}) { Get().Debugf(format, args...) }
// Info 输出 Info 级别日志
// @param args 日志内容
func Info(args ...interface{}) { Get().Info(args...) }
// Infof 格式化输出 Info 级别日志
// @param format 格式字符串
// @param args 格式化参数
func Infof(format string, args ...interface{}) { Get().Infof(format, args...) }
// Warn 输出 Warn 级别日志
// @param args 日志内容
func Warn(args ...interface{}) { Get().Warn(args...) }
// Warnf 格式化输出 Warn 级别日志
// @param format 格式字符串
// @param args 格式化参数
func Warnf(format string, args ...interface{}) { Get().Warnf(format, args...) }
// Error 输出 Error 级别日志
// @param args 日志内容
func Error(args ...interface{}) { Get().Error(args...) }
// Errorf 格式化输出 Error 级别日志
// @param format 格式字符串
// @param args 格式化参数
func Errorf(format string, args ...interface{}) { Get().Errorf(format, args...) }
// Fatal 输出 Fatal 级别日志并退出
// @param args 日志内容
func Fatal(args ...interface{}) { Get().Fatal(args...) }
// Fatalf 格式化输出 Fatal 级别日志并退出
// @param format 格式字符串
// @param args 格式化参数
func Fatalf(format string, args ...interface{}) { Get().Fatalf(format, args...) }
// WithField 添加单字段上下文
// @param key 字段名
// @param value 字段值
// @return *logrus.Entry 带字段的日志条目
// @author sunct
func WithField(key string, value interface{}) *logrus.Entry {
return Get().WithField(key, value)
}
// WithFields 添加多字段上下文
// @param fields 字段映射
// @return *logrus.Entry 带字段的日志条目
// @author sunct
func WithFields(fields logrus.Fields) *logrus.Entry {
return Get().WithFields(fields)
}
func FromContext(ctx context.Context) *logrus.Entry {
entry := Get().WithField("app", "resume-platform")
if reqId := ctx.Value(constant.RequestIdKey); reqId != nil {
entry = entry.WithField("request-id", reqId)
}
if route := ctx.Value(constant.RequestRouteKey); route != nil {
entry = entry.WithField("route", route)
}
if clientIP := ctx.Value(constant.ClientIP); clientIP != nil {
entry = entry.WithField("client-ip", clientIP)
}
return entry
}
func CtxDebug(ctx context.Context, args ...interface{}) {
FromContext(ctx).Debug(args...)
}
func CtxDebugf(ctx context.Context, format string, args ...interface{}) {
FromContext(ctx).Debugf(format, args...)
}
func CtxInfo(ctx context.Context, args ...interface{}) {
FromContext(ctx).Info(args...)
}
func CtxInfof(ctx context.Context, format string, args ...interface{}) {
FromContext(ctx).Infof(format, args...)
}
func CtxWarn(ctx context.Context, args ...interface{}) {
FromContext(ctx).Warn(args...)
}
func CtxWarnf(ctx context.Context, format string, args ...interface{}) {
FromContext(ctx).Warnf(format, args...)
}
func CtxError(ctx context.Context, args ...interface{}) {
FromContext(ctx).Error(args...)
}
func CtxErrorf(ctx context.Context, format string, args ...interface{}) {
FromContext(ctx).Errorf(format, args...)
}
func CtxFatal(ctx context.Context, args ...interface{}) {
FromContext(ctx).Fatal(args...)
}
func CtxFatalf(ctx context.Context, format string, args ...interface{}) {
FromContext(ctx).Fatalf(format, args...)
}
+61
View File
@@ -0,0 +1,61 @@
package utils
import (
"encoding/json"
"strings"
)
func CleanAIResponse(response string) string {
result := strings.TrimSpace(response)
for i := 0; i < 10; i++ {
changed := false
if strings.HasPrefix(result, "```json") {
result = strings.TrimSpace(strings.TrimPrefix(result, "```json"))
changed = true
} else if strings.HasPrefix(result, "```") {
result = strings.TrimSpace(strings.TrimPrefix(result, "```"))
changed = true
}
if strings.HasSuffix(result, "```") {
result = strings.TrimSpace(strings.TrimSuffix(result, "```"))
changed = true
}
if !changed {
break
}
}
firstBrace := strings.Index(result, "{")
lastBrace := strings.LastIndex(result, "}")
if firstBrace >= 0 && lastBrace >= 0 && lastBrace > firstBrace {
result = result[firstBrace : lastBrace+1]
}
result = strings.ReplaceAll(result, "`", "")
result = strings.ReplaceAll(result, "\\n", "\n")
result = strings.ReplaceAll(result, "\\r", "\r")
result = strings.ReplaceAll(result, "\\t", "\t")
return strings.TrimSpace(result)
}
func IsValidJSON(s string) bool {
var js json.RawMessage
return json.Unmarshal([]byte(s), &js) == nil
}
func ExtractJSONFromText(text string) string {
firstBrace := strings.Index(text, "{")
lastBrace := strings.LastIndex(text, "}")
if firstBrace >= 0 && lastBrace >= 0 && lastBrace > firstBrace {
return text[firstBrace : lastBrace+1]
}
return ""
}
+32
View File
@@ -0,0 +1,32 @@
package utils
func Min(a, b int) int {
if a < b {
return a
}
return b
}
func Max(a, b int) int {
if a > b {
return a
}
return b
}
func Clamp(value, min, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}
+59
View File
@@ -0,0 +1,59 @@
package utils
import (
"fmt"
"strings"
"unicode"
)
func ConvertToString(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", val)
case float32, float64:
return fmt.Sprintf("%.0f", val)
default:
return fmt.Sprintf("%v", val)
}
}
func ConvertToStringSlice(v []interface{}) []interface{} {
if v == nil {
return []interface{}{}
}
result := make([]interface{}, 0, len(v))
for _, item := range v {
result = append(result, ConvertToString(item))
}
return result
}
func CleanText(text string) string {
var builder strings.Builder
for _, r := range text {
if unicode.IsPrint(r) || r == '\n' || r == '\r' || r == '\t' {
builder.WriteRune(r)
} else {
builder.WriteRune(' ')
}
}
result := builder.String()
result = strings.ReplaceAll(result, "\r\n", "\n")
result = strings.ReplaceAll(result, "\r", "\n")
for strings.Contains(result, "\n\n\n") {
result = strings.ReplaceAll(result, "\n\n\n", "\n\n")
}
for strings.Contains(result, " ") {
result = strings.ReplaceAll(result, " ", " ")
}
return strings.TrimSpace(result)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

+97
View File
@@ -0,0 +1,97 @@
// admin-menus.js - 菜单管理页面功能
function openAddMenuModal() {
document.getElementById('menuId').value = '';
document.getElementById('menuName').value = '';
document.getElementById('menuIcon').value = '';
document.getElementById('menuPath').value = '';
document.getElementById('menuSortOrder').value = '0';
document.getElementById('menuParentID').value = '';
document.getElementById('menuIsEnabled').checked = true;
document.getElementById('menuIsFixed').checked = false;
document.getElementById('menuModalTitle').textContent = '添加菜单';
document.getElementById('menuModal').style.display = 'flex';
}
function closeMenuModal() {
document.getElementById('menuModal').style.display = 'none';
}
function editMenu(id) {
fetch(`/api/admin/menus/${id}`)
.then(response => response.json())
.then(menu => {
document.getElementById('menuId').value = menu.id;
document.getElementById('menuName').value = menu.name;
document.getElementById('menuIcon').value = menu.icon || '';
document.getElementById('menuPath').value = menu.path;
document.getElementById('menuSortOrder').value = menu.sort_order || '0';
document.getElementById('menuParentID').value = menu.parent_id || '';
document.getElementById('menuIsEnabled').checked = menu.is_enabled;
document.getElementById('menuIsFixed').checked = menu.is_fixed;
document.getElementById('menuModalTitle').textContent = '编辑菜单';
document.getElementById('menuModal').style.display = 'flex';
})
.catch(error => {
showCustomAlert('error', '获取失败', '获取菜单信息失败');
});
}
function saveMenu(event) {
event.preventDefault();
const menuId = document.getElementById('menuId').value;
const data = {
name: document.getElementById('menuName').value,
icon: document.getElementById('menuIcon').value,
path: document.getElementById('menuPath').value,
sort_order: parseInt(document.getElementById('menuSortOrder').value) || 0,
parent_id: document.getElementById('menuParentID').value || null,
is_enabled: document.getElementById('menuIsEnabled').checked,
is_fixed: document.getElementById('menuIsFixed').checked
};
const url = menuId ? `/api/admin/menus/${menuId}` : '/api/admin/menus';
const method = menuId ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '保存成功', menuId ? '菜单更新成功' : '菜单添加成功');
closeMenuModal();
window.location.reload();
} else {
showCustomAlert('error', '保存失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '保存失败', '网络请求失败');
});
}
function deleteMenu(id) {
if (!confirm('确定要删除该菜单吗?')) return;
fetch(`/api/admin/menus/${id}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '删除成功', '菜单删除成功');
window.location.reload();
} else {
showCustomAlert('error', '删除失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '删除失败', '网络请求失败');
});
}
+101
View File
@@ -0,0 +1,101 @@
// admin-portfolio.js - 作品集管理页面功能
function openAddPortfolioModal() {
document.getElementById('portfolioId').value = '';
document.getElementById('portfolioName').value = '';
document.getElementById('portfolioDescription').value = '';
document.getElementById('portfolioTechStack').value = '';
document.getElementById('portfolioURL').value = '';
document.getElementById('portfolioStartDate').value = '';
document.getElementById('portfolioEndDate').value = '';
document.getElementById('portfolioModalTitle').textContent = '添加项目';
document.getElementById('portfolioModal').style.display = 'flex';
}
function closePortfolioModal() {
document.getElementById('portfolioModal').style.display = 'none';
}
function editPortfolio(id) {
fetch(`/api/admin/portfolio/${id}`)
.then(response => response.json())
.then(item => {
document.getElementById('portfolioId').value = item.id;
document.getElementById('portfolioName').value = item.name;
document.getElementById('portfolioDescription').value = item.description;
document.getElementById('portfolioTechStack').value = item.tech_stack ? item.tech_stack.join(', ') : '';
document.getElementById('portfolioURL').value = item.url || '';
document.getElementById('portfolioStartDate').value = item.start_date || '';
document.getElementById('portfolioEndDate').value = item.end_date || '';
document.getElementById('portfolioModalTitle').textContent = '编辑项目';
document.getElementById('portfolioModal').style.display = 'flex';
})
.catch(error => {
showCustomAlert('error', '获取失败', '获取项目信息失败');
});
}
function savePortfolio(event) {
event.preventDefault();
const portfolioId = document.getElementById('portfolioId').value;
const userId = document.getElementById('portfolioUserId').value;
const techStack = document.getElementById('portfolioTechStack').value
.split(',')
.map(s => s.trim())
.filter(s => s);
const data = {
user_id: userId,
name: document.getElementById('portfolioName').value,
description: document.getElementById('portfolioDescription').value,
tech_stack: techStack,
url: document.getElementById('portfolioURL').value,
start_date: document.getElementById('portfolioStartDate').value,
end_date: document.getElementById('portfolioEndDate').value
};
const url = portfolioId ? `/api/admin/portfolio/${portfolioId}` : '/api/admin/portfolio';
const method = portfolioId ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '保存成功', portfolioId ? '项目更新成功' : '项目添加成功');
closePortfolioModal();
window.location.reload();
} else {
showCustomAlert('error', '保存失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '保存失败', '网络请求失败');
});
}
function deletePortfolio(id) {
if (!confirm('确定要删除该项目吗?')) return;
fetch(`/api/admin/portfolio/${id}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '删除成功', '项目删除成功');
window.location.reload();
} else {
showCustomAlert('error', '删除失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '删除失败', '网络请求失败');
});
}
+38
View File
@@ -0,0 +1,38 @@
// admin-quiz.js - 面试题库页面功能
function generateQuestions() {
showCustomAlert('info', '生成中', '正在生成面试题目,请稍候...');
}
function filterFavorites() {
showCustomAlert('info', '筛选收藏', '已筛选收藏的题目');
}
function filterWrong() {
showCustomAlert('info', '筛选错题', '已筛选错题');
}
function clearAllRecords() {
if (!confirm('确定要清空所有答题记录吗?')) return;
showCustomAlert('success', '清空成功', '记录已清空');
}
function deleteFavorite(id) {
if (!confirm('确定要删除该收藏吗?')) return;
fetch(`/api/admin/favorites/${id}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '删除成功', '收藏已删除');
window.location.reload();
} else {
showCustomAlert('error', '删除失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '删除失败', '网络请求失败');
});
}
+33
View File
@@ -0,0 +1,33 @@
// admin-resume.js - 个人简历页面功能
function createResume(userId) {
fetch(`/api/admin/users/${userId}/resume`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
basic_info: {
name: '',
title: '',
email: '',
phone: '',
location: '',
website: '',
summary: ''
}
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '创建成功', '简历创建成功');
window.location.reload();
} else {
showCustomAlert('error', '创建失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '创建失败', '网络请求失败');
});
}
+61
View File
@@ -0,0 +1,61 @@
// admin-settings.js - 系统设置页面功能
function saveWebsiteConfig(event) {
event.preventDefault();
const data = {
website_domain: document.getElementById('websiteDomain').value,
website_title: document.getElementById('websiteTitle').value,
website_description: document.getElementById('websiteDesc').value,
website_keywords: document.getElementById('websiteKeywords').value,
website_logo: document.getElementById('websiteLogo').value
};
fetch('/api/admin/settings/website', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '保存成功', '网站配置已保存');
} else {
showCustomAlert('error', '保存失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '保存失败', '网络请求失败');
});
}
function saveAIConfig(event) {
event.preventDefault();
const data = {
ai_provider: document.getElementById('aiProvider').value,
ai_api_key: document.getElementById('aiAPIKey').value,
ai_api_secret: document.getElementById('aiAPISecret').value
};
fetch('/api/admin/settings/ai', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '保存成功', 'AI配置已保存');
} else {
showCustomAlert('error', '保存失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '保存失败', '网络请求失败');
});
}
+91
View File
@@ -0,0 +1,91 @@
// admin-users.js - 人员管理页面功能
function openAddUserModal() {
document.getElementById('userId').value = '';
document.getElementById('userName').value = '';
document.getElementById('userUsername').value = '';
document.getElementById('userEmail').value = '';
document.getElementById('userPhone').value = '';
document.getElementById('userRole').value = 'user';
document.getElementById('userModalTitle').textContent = '添加用户';
document.getElementById('userModal').style.display = 'flex';
}
function closeUserModal() {
document.getElementById('userModal').style.display = 'none';
}
function editUser(id) {
fetch(`/api/admin/users/${id}`)
.then(response => response.json())
.then(user => {
document.getElementById('userId').value = user.id;
document.getElementById('userName').value = user.name;
document.getElementById('userUsername').value = user.username;
document.getElementById('userEmail').value = user.email;
document.getElementById('userPhone').value = user.phone || '';
document.getElementById('userRole').value = user.role;
document.getElementById('userModalTitle').textContent = '编辑用户';
document.getElementById('userModal').style.display = 'flex';
})
.catch(error => {
showCustomAlert('error', '获取失败', '获取用户信息失败');
});
}
function saveUser(event) {
event.preventDefault();
const userId = document.getElementById('userId').value;
const data = {
name: document.getElementById('userName').value,
username: document.getElementById('userUsername').value,
email: document.getElementById('userEmail').value,
phone: document.getElementById('userPhone').value,
role: document.getElementById('userRole').value
};
const url = userId ? `/api/admin/users/${userId}` : '/api/admin/users';
const method = userId ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '保存成功', userId ? '用户更新成功' : '用户添加成功');
closeUserModal();
window.location.reload();
} else {
showCustomAlert('error', '保存失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '保存失败', '网络请求失败');
});
}
function deleteUser(id) {
if (!confirm('确定要删除该用户吗?')) return;
fetch(`/api/admin/users/${id}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '删除成功', '用户删除成功');
window.location.reload();
} else {
showCustomAlert('error', '删除失败', result.message || '操作失败');
}
})
.catch(error => {
showCustomAlert('error', '删除失败', '网络请求失败');
});
}
+97
View File
@@ -0,0 +1,97 @@
// admin.js - 后台管理公共功能模块
/**
* 侧边栏切换
*/
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const mainArea = document.querySelector('.main-area');
const toggleIcon = document.querySelector('.sidebar-toggle i');
if (sidebar && mainArea && toggleIcon) {
if (sidebar.classList.contains('collapsed')) {
sidebar.classList.remove('collapsed');
mainArea.style.marginLeft = '256px';
toggleIcon.className = 'fas fa-chevron-left';
} else {
sidebar.classList.add('collapsed');
mainArea.style.marginLeft = '0';
toggleIcon.className = 'fas fa-chevron-right';
}
}
}
/**
* 自定义弹窗提示
* @param {string} type - 类型: success, error, info, warning
* @param {string} title - 标题
* @param {string} message - 消息内容
*/
function showCustomAlert(type, title, message) {
const modal = document.getElementById('customAlertModal');
const icon = document.getElementById('alertIcon');
const alertTitle = document.getElementById('alertTitle');
const alertMessage = document.getElementById('alertMessage');
if (!modal || !icon || !alertTitle || !alertMessage) return;
const icons = {
success: '<i class="fas fa-check-circle" style="color: #10b981;"></i>',
error: '<i class="fas fa-exclamation-circle" style="color: #ef4444;"></i>',
info: '<i class="fas fa-info-circle" style="color: #3b82f6;"></i>',
warning: '<i class="fas fa-exclamation-triangle" style="color: #f59e0b;"></i>'
};
icon.innerHTML = icons[type] || icons.info;
alertTitle.textContent = title;
alertMessage.textContent = message;
modal.style.display = 'flex';
}
/**
* 关闭自定义弹窗
*/
function closeCustomAlert() {
const modal = document.getElementById('customAlertModal');
if (modal) modal.style.display = 'none';
}
/**
* HTML转义,防止XSS攻击
* @param {string} text - 需要转义的文本
* @returns {string} 转义后的文本
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 验证邮箱格式
* @param {string} email - 邮箱地址
* @returns {boolean} 是否有效
*/
function isValidEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
/**
* 验证手机号格式
* @param {string} phone - 手机号码
* @returns {boolean} 是否有效
*/
function isValidPhone(phone) {
const re = /^1[3-9]\d{9}$/;
return re.test(phone);
}
// 点击模态框外部关闭弹窗
document.addEventListener('click', function(e) {
const alertModal = document.getElementById('customAlertModal');
if (alertModal && e.target === alertModal) {
closeCustomAlert();
}
});
+24
View File
@@ -0,0 +1,24 @@
function revealEmail(element) {
const maskedEmail = element.getAttribute('data-email');
if (!maskedEmail) return;
if (element.classList.contains('revealed')) {
window.location.href = 'mailto:' + maskedEmail;
return;
}
element.classList.add('revealed');
const emailParts = maskedEmail.split('@');
if (emailParts.length === 2) {
element.innerHTML = '<i class="fas fa-envelope"></i> <a href="mailto:' + maskedEmail + '" style="color: inherit; text-decoration: none;">' + maskedEmail + '</a>';
}
}
document.addEventListener('DOMContentLoaded', function() {
const emailElements = document.querySelectorAll('.email-protected');
emailElements.forEach(function(el) {
el.style.cursor = 'pointer';
el.title = '点击查看完整邮箱';
});
});
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>未授权访问 - 简历管理后台</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
padding: 3.75rem 2.50rem;
}
.error-icon {
width: 7.50rem;
height: 7.50rem;
margin: 0 auto 2rem;
border-radius: 2rem;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1.25rem 3.75rem rgba(251, 191, 36, 0.3);
animation: float 6s ease-in-out infinite;
}
.error-icon i {
font-size: 3.50rem;
color: #d97706;
}
.error-number {
font-size: 5rem;
font-weight: 700;
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1rem;
letter-spacing: -0.25rem;
}
.error-title {
font-size: 1.50rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.75rem;
}
.error-description {
font-size: 0.94rem;
color: #64748b;
max-width: 26.25rem;
margin: 0 auto 2rem;
line-height: 1.6;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.50rem;
padding: 0.88rem 2rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 0.88rem;
font-size: 0.94rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0.25rem 0.94rem rgba(99, 102, 241, 0.4);
text-decoration: none;
}
.btn-primary:hover {
transform: translateY(-0.12rem);
box-shadow: 0 0.38rem 1.25rem rgba(99, 102, 241, 0.5);
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-0.62rem); }
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
z-index: -1;
}
.bg-decoration::before {
content: '';
position: absolute;
top: -12.50rem;
right: -6.25rem;
width: 25rem;
height: 25rem;
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
.bg-decoration::after {
content: '';
position: absolute;
bottom: -9.38rem;
left: -6.25rem;
width: 18.75rem;
height: 18.75rem;
background: radial-gradient(circle, rgba(251, 191, 36, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
</style>
</head>
<body>
<div class="bg-decoration"></div>
<div class="error-container">
<div class="error-icon">
<i class="fas fa-lock"></i>
</div>
<div class="error-number">401</div>
<h1 class="error-title">未授权访问</h1>
<p class="error-description">
您没有权限访问此页面。请先登录后台管理系统,或联系管理员获取访问权限。
</p>
</div>
</body>
</html>
+180
View File
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面未找到 - 简历管理后台</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
padding: 3.75rem 2.50rem;
}
.error-icon {
width: 7.50rem;
height: 7.50rem;
margin: 0 auto 2rem;
border-radius: 2rem;
background: linear-gradient(135deg, #cffafe 0%, #a5f3fc 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1.25rem 3.75rem rgba(6, 182, 212, 0.3);
animation: float 6s ease-in-out infinite;
}
.error-icon i {
font-size: 3.50rem;
color: #0891b2;
}
.error-number {
font-size: 5rem;
font-weight: 700;
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1rem;
letter-spacing: -0.25rem;
}
.error-title {
font-size: 1.50rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.75rem;
}
.error-description {
font-size: 0.94rem;
color: #64748b;
max-width: 26.25rem;
margin: 0 auto 2rem;
line-height: 1.6;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.50rem;
padding: 0.88rem 2rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 0.88rem;
font-size: 0.94rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0.25rem 0.94rem rgba(99, 102, 241, 0.4);
text-decoration: none;
}
.btn-primary:hover {
transform: translateY(-0.12rem);
box-shadow: 0 0.38rem 1.25rem rgba(99, 102, 241, 0.5);
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.50rem;
padding: 0.88rem 2rem;
background: white;
color: #64748b;
border: 0.06rem solid #e2e8f0;
border-radius: 0.88rem;
font-size: 0.94rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-left: 0.75rem;
text-decoration: none;
}
.btn-secondary:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #334155;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-0.62rem); }
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
z-index: -1;
}
.bg-decoration::before {
content: '';
position: absolute;
top: -12.50rem;
right: -6.25rem;
width: 25rem;
height: 25rem;
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
.bg-decoration::after {
content: '';
position: absolute;
bottom: -9.38rem;
left: -6.25rem;
width: 18.75rem;
height: 18.75rem;
background: radial-gradient(circle, rgba(6, 182, 212, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
</style>
</head>
<body>
<div class="bg-decoration"></div>
<div class="error-container">
<div class="error-icon">
<i class="fas fa-search"></i>
</div>
<div class="error-number">404</div>
<h1 class="error-title">页面未找到</h1>
<p class="error-description">
您访问的页面不存在或已被移除。请检查网址是否正确,或返回后台首页继续操作。
</p>
<div class="flex items-center justify-center gap-3">
<a href="/dashboard/home" class="btn-primary">
<i class="fas fa-home"></i> 返回后台首页
</a>
<button onclick="history.back()" class="btn-secondary">
<i class="fas fa-arrow-left"></i> 返回上一页
</button>
</div>
</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<div class="stat-card bg-gradient-to-br from-blue-500 to-blue-600">
<div class="stat-icon">
<i class="fas fa-users"></i>
</div>
<div>
<p class="stat-label">总用户数</p>
<h3 class="stat-number">{{.Stats.TotalUsers}}</h3>
</div>
</div>
<div class="stat-card bg-gradient-to-br from-emerald-500 to-emerald-600">
<div class="stat-icon">
<i class="fas fa-file-alt"></i>
</div>
<div>
<p class="stat-label">总简历数</p>
<h3 class="stat-number">{{.Stats.TotalResumes}}</h3>
</div>
</div>
<div class="stat-card bg-gradient-to-br from-violet-500 to-violet-600">
<div class="stat-icon">
<i class="fas fa-folder-open"></i>
</div>
<div>
<p class="stat-label">作品集项目</p>
<h3 class="stat-number">{{.Stats.TotalPortfolio}}</h3>
</div>
</div>
<div class="stat-card bg-gradient-to-br from-orange-500 to-orange-600">
<div class="stat-icon">
<i class="fas fa-clipboard-list"></i>
</div>
<div>
<p class="stat-label">答题记录</p>
<h3 class="stat-number">{{.Stats.TotalQuizRecords}}</h3>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-5 mt-5">
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
<div class="w-12 h-12 rounded-lg bg-amber-50 flex items-center justify-center text-amber-500">
<i class="fas fa-star text-xl"></i>
</div>
<div>
<p class="text-sm text-slate-500">收藏题目</p>
<h4 class="text-xl font-bold text-slate-800">{{.Stats.TotalFavorites}}</h4>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
<div class="w-12 h-12 rounded-lg bg-rose-50 flex items-center justify-center text-rose-500">
<i class="fas fa-times-circle text-xl"></i>
</div>
<div>
<p class="text-sm text-slate-500">错题总数</p>
<h4 class="text-xl font-bold text-slate-800">{{.Stats.TotalWrongAnswers}}</h4>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
<div class="w-12 h-12 rounded-lg bg-sky-50 flex items-center justify-center text-sky-500">
<i class="fas fa-clock text-xl"></i>
</div>
<div>
<p class="text-sm text-slate-500">运行时长</p>
<h4 class="text-xl font-bold text-slate-800">{{.Uptime}}</h4>
</div>
</div>
</div>
<div class="mt-5 grid grid-cols-1 lg:grid-cols-3 gap-5">
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-users text-blue-500"></i> 最近用户
</h3>
<a href="{{.AdminPath}}/users" class="btn btn-secondary btn-sm">
<i class="fas fa-list"></i> 查看全部
</a>
</div>
<div class="divide-y divide-slate-50">
{{ range .RecentUsers }}
<div class="px-5 py-4 flex items-center gap-4 hover:bg-slate-50 transition-colors">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-medium text-sm flex-shrink-0">
{{if .Name}}{{substr .Name 0 1}}{{else}}U{{end}}
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-slate-800 truncate">{{.Name}}</p>
<p class="text-sm text-slate-500 truncate">{{.Email}}</p>
</div>
<div class="flex gap-2 flex-shrink-0">
<a href="{{$.AdminPath}}/user/{{.ID}}/resume" class="btn btn-primary btn-xs">
<i class="fas fa-file-alt"></i> 简历
</a>
<a href="{{$.AdminPath}}/user/{{.ID}}/portfolio" class="btn btn-info btn-xs">
<i class="fas fa-folder-open"></i> 作品集
</a>
<a href="{{$.AdminPath}}/user/{{.ID}}/quiz" class="btn btn-success btn-xs">
<i class="fas fa-question-circle"></i> 答题
</a>
</div>
</div>
{{ else }}
<div class="text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-users text-slate-400 text-2xl"></i>
</div>
<p class="text-slate-500">暂无用户</p>
</div>
{{ end }}
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-file-alt text-emerald-500"></i> 最近简历
</h3>
<a href="{{.AdminPath}}/resumes" class="btn btn-secondary btn-sm">
<i class="fas fa-list"></i> 全部
</a>
</div>
<div class="divide-y divide-slate-50">
{{ range .RecentResumes }}
<div class="px-5 py-4 flex items-center gap-3 hover:bg-slate-50 transition-colors">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center text-white flex-shrink-0">
<i class="fas fa-file-alt"></i>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-slate-800 truncate text-sm">{{.BasicInfo.Name}}</p>
<p class="text-xs text-slate-500 truncate">{{.BasicInfo.Title}}</p>
</div>
<a href="/{{.Route}}" target="_blank" class="text-blue-500 hover:text-blue-600 text-sm flex-shrink-0">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
{{ else }}
<div class="text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-file-alt text-slate-400 text-2xl"></i>
</div>
<p class="text-slate-500">暂无简历</p>
</div>
{{ end }}
</div>
</div>
</div>
<div class="mt-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
<a href="{{.AdminPath}}/users" class="quick-action-card">
<div class="quick-action-icon bg-blue-50 text-blue-500">
<i class="fas fa-user-plus"></i>
</div>
<div>
<h4 class="font-medium text-slate-800">用户管理</h4>
<p class="text-sm text-slate-500">添加/编辑用户信息</p>
</div>
<i class="fas fa-chevron-right text-slate-300"></i>
</a>
<a href="{{.AdminPath}}/resumes" class="quick-action-card">
<div class="quick-action-icon bg-emerald-50 text-emerald-500">
<i class="fas fa-file-medical"></i>
</div>
<div>
<h4 class="font-medium text-slate-800">简历管理</h4>
<p class="text-sm text-slate-500">查看和编辑简历</p>
</div>
<i class="fas fa-chevron-right text-slate-300"></i>
</a>
<a href="{{.AdminPath}}/portfolios" class="quick-action-card">
<div class="quick-action-icon bg-violet-50 text-violet-500">
<i class="fas fa-folder-plus"></i>
</div>
<div>
<h4 class="font-medium text-slate-800">作品集管理</h4>
<p class="text-sm text-slate-500">管理项目作品集</p>
</div>
<i class="fas fa-chevron-right text-slate-300"></i>
</a>
<a href="{{.AdminPath}}/settings" class="quick-action-card">
<div class="quick-action-icon bg-slate-50 text-slate-500">
<i class="fas fa-cog"></i>
</div>
<div>
<h4 class="font-medium text-slate-800">系统设置</h4>
<p class="text-sm text-slate-500">配置系统参数</p>
</div>
<i class="fas fa-chevron-right text-slate-300"></i>
</a>
</div>
<style>
.stat-card {
position: relative;
border-radius: 0.5rem;
padding: 1.25rem;
color: white;
display: flex;
align-items: center;
gap: 1rem;
overflow: hidden;
box-shadow: 0 0.25rem 0.38rem -0.06rem rgba(0, 0, 0, 0.1);
}
.stat-card::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 7.50rem;
height: 7.50rem;
background: rgba(255,255,255,0.1);
border-radius: 50%;
}
.stat-card::after {
content: '';
position: absolute;
bottom: -30%;
right: 10%;
width: 5rem;
height: 5rem;
background: rgba(255,255,255,0.08);
border-radius: 50%;
}
.stat-icon {
width: 3.25rem;
height: 3.25rem;
background: rgba(255,255,255,0.2);
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
flex-shrink: 0;
position: relative;
z-index: 1;
backdrop-filter: blur(0.25rem);
}
.stat-label {
font-size: 0.875rem;
opacity: 0.9;
margin-bottom: 0.25rem;
position: relative;
z-index: 1;
}
.stat-number {
font-size: 1.75rem;
font-weight: 700;
position: relative;
z-index: 1;
}
.quick-action-card {
background: white;
border: 0.06rem solid #f1f5f9;
border-radius: 0.75rem;
padding: 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
text-decoration: none;
transition: all 0.2s ease;
box-shadow: 0 0.06rem 0.12rem rgba(0,0,0,0.04);
}
.quick-action-card:hover {
border-color: #e2e8f0;
transform: translateY(-0.12rem);
box-shadow: 0 0.25rem 0.75rem rgba(0,0,0,0.06);
}
.quick-action-icon {
width: 2.75rem;
height: 2.75rem;
border-radius: 0.625rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.125rem;
flex-shrink: 0;
}
</style>
+483
View File
@@ -0,0 +1,483 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .SystemConfig.WebsiteTitle }} - 后台登录</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #7B8EA2;
--primary-hover: #6B7E92;
--accent: #987E68;
--text-primary: #2F343A;
--text-secondary: #6C737C;
--text-muted: #9CA3AF;
--bg: #F8F7F4;
--bg-card: #FFFFFF;
--border: #E5E3DF;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Noto Sans SC', sans-serif;
background-color: var(--bg);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 26.25rem;
padding: 3rem;
background: var(--bg-card);
border-radius: 1rem;
box-shadow: 0 0.25rem 1.50rem rgba(47, 52, 58, 0.08);
border: 0.06rem solid var(--border);
}
.brand-section {
text-align: center;
margin-bottom: 2.50rem;
}
.brand-logo {
width: 3.50rem;
height: 3.50rem;
margin: 0 auto 1.25rem;
background: var(--primary);
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.50rem;
transition: all 0.3s ease;
}
.brand-logo img {
border-radius: 0.50rem;
}
.brand-logo:hover {
transform: scale(1.05);
}
.brand-title {
font-size: 1.38rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.38rem;
letter-spacing: 0.12rem;
}
.brand-subtitle {
font-size: 0.81rem;
color: var(--text-secondary);
}
.input-group {
margin-bottom: 1.25rem;
}
.input-group label {
display: block;
font-size: 0.81rem;
color: var(--text-secondary);
margin-bottom: 0.50rem;
font-weight: 500;
}
.input-group input {
width: 100%;
padding: 0.75rem 0.88rem;
border: 0.06rem solid var(--border);
border-radius: 0.50rem;
font-size: 0.88rem;
color: var(--text-primary);
background: var(--bg);
transition: all 0.25s ease;
outline: none;
font-family: inherit;
}
.input-group input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 0.19rem rgba(123, 142, 162, 0.1);
background: white;
}
.input-group input::placeholder {
color: var(--text-muted);
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.88rem;
color: var(--text-muted);
pointer-events: none;
}
.input-group input {
padding-left: 2.50rem;
}
.input-toggle {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.88rem;
color: var(--text-muted);
cursor: pointer;
transition: color 0.2s ease;
}
.input-toggle:hover {
color: var(--primary);
}
.captcha-row {
display: flex;
gap: 0.75rem;
align-items: center;
}
.captcha-row .input-group {
flex: 1;
margin-bottom: 0;
}
.captcha-img {
width: 6.25rem;
height: 2.75rem;
border-radius: 0.50rem;
cursor: pointer;
border: 0.06rem solid var(--border);
background: var(--bg);
transition: all 0.25s ease;
}
.captcha-img:hover {
border-color: var(--primary);
transform: scale(1.02);
}
.btn-login {
width: 100%;
padding: 0.81rem;
border: none;
border-radius: 0.50rem;
font-size: 0.88rem;
font-weight: 500;
color: white;
background: var(--primary);
cursor: pointer;
transition: all 0.25s ease;
margin-top: 1.50rem;
letter-spacing: 0.06rem;
}
.btn-login:hover {
background: var(--primary-hover);
transform: translateY(-0.06rem);
box-shadow: 0 0.25rem 0.75rem rgba(123, 142, 162, 0.25);
}
.btn-login:active {
transform: translateY(0);
box-shadow: none;
}
.btn-login:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.footer-text {
text-align: center;
margin-top: 2rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.alert-overlay {
position: fixed;
inset: 0;
background: rgba(47, 52, 58, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.alert-overlay.show {
opacity: 1;
visibility: visible;
}
.alert-box {
background: white;
border-radius: 0.75rem;
padding: 2rem;
max-width: 22.50rem;
width: 90%;
text-align: center;
box-shadow: 0 0.75rem 2.50rem rgba(47, 52, 58, 0.15);
transform: scale(0.9) translateY(1.25rem);
transition: all 0.3s ease;
}
.alert-overlay.show .alert-box {
transform: scale(1) translateY(0);
}
.alert-icon {
width: 3rem;
height: 3rem;
margin: 0 auto 1rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.38rem;
}
.alert-icon.error {
background: #FEF2F2;
color: #DC2626;
}
.alert-icon.success {
background: #ECFDF5;
color: #16A34A;
}
.alert-icon.info {
background: #EFF6FF;
color: #2563EB;
}
.alert-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.50rem;
}
.alert-message {
font-size: 0.81rem;
color: var(--text-secondary);
margin-bottom: 1.25rem;
line-height: 1.6;
}
.btn-alert {
padding: 0.62rem 1.50rem;
border: none;
border-radius: 0.38rem;
font-size: 0.81rem;
font-weight: 500;
color: white;
background: var(--primary);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-alert:hover {
background: var(--primary-hover);
}
</style>
</head>
<body>
<div class="login-container">
<div class="brand-section">
<div class="brand-logo">
{{ if .SystemConfig.LogoPath }}
<img src="{{.SystemConfig.LogoPath}}" alt="Logo">
{{ else }}
<i class="fas fa-file-alt"></i>
{{ end }}
</div>
<div class="brand-title">{{ .SystemConfig.WebsiteTitle }}</div>
<div class="brand-subtitle">{{ .SystemConfig.WebsiteDesc }}</div>
</div>
<form id="loginForm" onsubmit="login(event)">
<div class="input-group">
<label for="username">用户名</label>
<div class="input-wrapper">
<i class="fas fa-user input-icon"></i>
<input type="text" id="username" placeholder="请输入用户名" required>
</div>
</div>
<div class="input-group">
<label for="password">密码</label>
<div class="input-wrapper">
<i class="fas fa-lock input-icon"></i>
<input type="password" id="password" placeholder="请输入密码" required>
<i class="fas fa-eye input-toggle" id="passwordToggle" onclick="togglePassword()"></i>
</div>
</div>
<div class="captcha-row">
<div class="input-group">
<label for="captcha">验证码</label>
<div class="input-wrapper">
<i class="fas fa-shield-halved input-icon"></i>
<input type="text" id="captcha" placeholder="验证码" required>
</div>
</div>
<div class="flex flex-col justify-center">
<img id="captchaImg" src="" alt="验证码" class="captcha-img" onclick="refreshCaptcha()">
</div>
</div>
<button type="submit" id="loginBtn" class="btn-login">登 录</button>
</form>
<div class="footer-text">
© 2026 {{ .SystemConfig.WebsiteTitle }}. All rights reserved.
</div>
</div>
<div id="customAlert" class="alert-overlay">
<div class="alert-box" id="alertContent">
<div id="alertIcon" class="alert-icon"></div>
<h3 id="alertTitle" class="alert-title"></h3>
<p id="alertMessage" class="alert-message"></p>
<button onclick="closeCustomAlert()" class="btn-alert">确定</button>
</div>
</div>
<script>
const adminPath = "{{.adminPath}}";
let captchaToken = '';
function togglePassword() {
const password = document.getElementById('password');
const toggle = document.getElementById('passwordToggle');
if (password.type === 'password') {
password.type = 'text';
toggle.classList.remove('fa-eye');
toggle.classList.add('fa-eye-slash');
} else {
password.type = 'password';
toggle.classList.remove('fa-eye-slash');
toggle.classList.add('fa-eye');
}
}
function refreshCaptcha() {
const img = document.getElementById('captchaImg');
const xhr = new XMLHttpRequest();
xhr.open('GET', adminPath + '/captcha?' + Date.now(), true);
xhr.responseType = 'blob';
xhr.onload = function() {
if (this.status === 200) {
captchaToken = this.getResponseHeader('X-Captcha-Token') || '';
const blob = this.response;
img.src = URL.createObjectURL(blob);
}
};
xhr.send();
}
function showCustomAlert(title, message, type) {
const alert = document.getElementById('customAlert');
const icon = document.getElementById('alertIcon');
const titleEl = document.getElementById('alertTitle');
const messageEl = document.getElementById('alertMessage');
titleEl.textContent = title;
messageEl.textContent = message;
icon.className = 'alert-icon ' + type;
if (type === 'error') {
icon.innerHTML = '<i class="fas fa-times-circle"></i>';
} else if (type === 'success') {
icon.innerHTML = '<i class="fas fa-check-circle"></i>';
} else {
icon.innerHTML = '<i class="fas fa-info-circle"></i>';
}
alert.classList.add('show');
}
function closeCustomAlert() {
const alert = document.getElementById('customAlert');
alert.classList.remove('show');
}
async function login(event) {
event.preventDefault();
const btn = document.getElementById('loginBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> 登录中...';
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const captcha = document.getElementById('captcha').value;
try {
const response = await fetch(adminPath + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
captcha,
captcha_token: captchaToken
})
});
const result = await response.json();
if (result.success) {
showCustomAlert('登录成功', '正在跳转...', 'success');
setTimeout(() => {
window.location.href = result.redirect || '/dashboard/home';
}, 1500);
} else {
showCustomAlert('登录失败', result.message || '请检查用户名、密码和验证码', 'error');
refreshCaptcha();
}
} catch (error) {
showCustomAlert('网络错误', '无法连接服务器,请稍后重试', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '登 录';
}
}
window.addEventListener('load', function() {
const xhr = new XMLHttpRequest();
xhr.open('GET', adminPath + '/captcha', true);
xhr.responseType = 'blob';
xhr.onload = function() {
if (this.status === 200) {
captchaToken = this.getResponseHeader('X-Captcha-Token') || '';
const blob = this.response;
document.getElementById('captchaImg').src = URL.createObjectURL(blob);
}
};
xhr.send();
});
</script>
</body>
</html>
+290
View File
@@ -0,0 +1,290 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-folder-open text-purple-500"></i> {{ if .Item.ID }}编辑项目{{ else }}新增项目{{ end }}
</h3>
<p class="text-sm text-slate-500 mt-1">{{ if .Item.ID }}修改作品集项目信息{{ else }}创建新的作品集项目{{ end }}</p>
</div>
</div>
<div class="p-6">
<form id="portfolioForm">
<input type="hidden" id="portfolioId" value="{{.Item.ID}}">
<div class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">选择人员 <span class="text-red-500">*</span></label>
<select id="portfolioUserId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
<option value="">请选择人员</option>
{{ range .Users }}
<option value="{{.ID}}" {{ if eq .ID $.Item.UserID }}selected{{ end }}>{{.Name}} - {{.Email}}</option>
{{ end }}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目名称 <span class="text-red-500">*</span></label>
<input type="text" id="portfolioName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.Name}}" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目描述</label>
<textarea id="portfolioDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">{{.Item.Description}}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">技术栈</label>
<input type="text" id="portfolioTechStack" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{ join .Item.TechStack "," }}" placeholder="多个技术栈用逗号分隔">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目链接</label>
<input type="url" id="portfolioUrl" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.URL}}" placeholder="https://">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
<input type="date" id="portfolioStartDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.StartDate}}">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
<input type="date" id="portfolioEndDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.EndDate}}">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">访问密码</label>
<input type="password" id="portfolioPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.Password}}" placeholder="留空则无需密码">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">关联简历项目ID</label>
<div class="space-y-2">
<select id="portfolioProjectResumeId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="">请选择简历</option>
{{ range .Resumes }}
<option value="{{.ID}}" {{ if eq .ID $.Item.ResumeID }}selected{{ end }}>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</option>
{{ end }}
</select>
<select id="portfolioProjectId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="">请选择项目</option>
</select>
</div>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="portfolioShowInResume" {{ if .Item.ShowInResume }}checked{{ end }} class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500">
<span class="text-sm font-medium text-slate-700">在简历中显示</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="portfolioHidden" {{ if .Item.Hidden }}checked{{ end }} class="w-4 h-4 text-primary-600 rounded focus:ring-2 focus:ring-primary-500">
<span class="text-sm font-medium text-slate-700">隐藏项目</span>
</label>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6" style="height: calc(100vh - 26.25rem); min-height: 31.25rem;">
<div class="flex flex-col">
<label class="block text-sm font-medium text-slate-700 mb-1">项目文档 <span class="text-slate-400 text-xs">(Markdown格式,支持图片、表格、流程图)</span></label>
<div class="flex-1 relative overflow-hidden rounded-lg border border-slate-300">
<textarea id="portfolioDoc" class="w-full h-full px-4 py-2 font-mono text-sm resize-none">{{.Item.Doc}}</textarea>
</div>
</div>
<div class="flex flex-col">
<label class="block text-sm font-medium text-slate-700 mb-1">预览</label>
<div id="docPreview" class="flex-1 p-4 border border-slate-200 rounded-lg bg-slate-50 markdown-body overflow-y-auto"></div>
</div>
</div>
</div>
</form>
</div>
</div>
<div class="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 px-6 py-4 shadow-lg z-50">
<div class="flex items-center justify-center gap-3">
<button onclick="goBack()" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回
</button>
<button onclick="savePortfolio()" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</div>
<div style="height: 5rem;"></div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.0/github-markdown.min.css">
<script>
let simplemde;
document.addEventListener('DOMContentLoaded', function() {
simplemde = new SimpleMDE({
element: document.getElementById('portfolioDoc'),
toolbar: [
'bold', 'italic', 'strikethrough', 'heading',
'heading-smaller', 'heading-bigger', 'heading-1', 'heading-2', 'heading-3',
'|',
'code', 'quote', 'unordered-list', 'ordered-list',
'|',
'link', 'image', 'table', 'horizontal-rule',
'|',
'preview', 'side-by-side', 'fullscreen',
'|',
'guide'
],
spellChecker: false,
autoDownloadFontAwesome: true,
previewRender: function(plainText) {
return marked.parse(plainText) || '';
}
});
simplemde.codemirror.on('change', function() {
const text = simplemde.value();
const preview = document.getElementById('docPreview');
try {
preview.innerHTML = marked.parse(text) || '<p class="text-slate-400">暂无内容</p>';
} catch (err) {
preview.innerHTML = '<p class="text-red-500">预览解析错误</p>';
}
});
const initialText = simplemde.value();
if (initialText) {
try {
document.getElementById('docPreview').innerHTML = marked.parse(initialText) || '<p class="text-slate-400">暂无内容</p>';
} catch (err) {
document.getElementById('docPreview').innerHTML = '<p class="text-red-500">预览解析错误</p>';
}
} else {
document.getElementById('docPreview').innerHTML = '<p class="text-slate-400">暂无内容,开始编写项目文档...</p>';
}
document.getElementById('portfolioUserId').addEventListener('change', function() {
loadResumesByUser(this.value, '');
});
document.getElementById('portfolioProjectResumeId').addEventListener('change', function() {
loadProjectsByResume(this.value, 0);
});
const userId = document.getElementById('portfolioUserId').value;
const savedResumeId = "{{.Item.ResumeID}}";
const savedProjectId = {{ if .Item.ProjectID }}{{.Item.ProjectID}}{{ else }}0{{ end }};
if (userId) {
loadResumesByUser(userId, savedResumeId, savedProjectId);
}
});
function loadResumesByUser(userId, selectedResumeId, autoLoadProjectId) {
const resumeSelect = document.getElementById('portfolioProjectResumeId');
resumeSelect.innerHTML = '<option value="">请选择简历</option>';
if (!userId) return;
fetch('/api/user/resumes?user_id=' + userId)
.then(response => response.json())
.then(result => {
if (!result.success) return;
const resumes = result.data || [];
resumes.forEach(resume => {
const option = document.createElement('option');
option.value = resume.id;
option.textContent = (resume.basic_info.name || '未命名') + ' - ' + (resume.basic_info.title || '');
if (selectedResumeId && resume.id === selectedResumeId) {
option.selected = true;
}
resumeSelect.appendChild(option);
});
// 恢复选中后,按需加载该简历下的项目
if (selectedResumeId && autoLoadProjectId) {
loadProjectsByResume(selectedResumeId, autoLoadProjectId);
}
})
.catch(() => {});
}
function loadProjectsByResume(resumeId, selectedProjectId) {
const projectSelect = document.getElementById('portfolioProjectId');
projectSelect.innerHTML = '<option value="">请选择项目</option>';
if (!resumeId) return;
fetch('/dashboard/api/resume/' + resumeId)
.then(response => response.json())
.then(result => {
if (!result.success) return;
const resume = result.data || {};
const projects = resume.projects || [];
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.name;
if (selectedProjectId && project.id === selectedProjectId) {
option.selected = true;
}
projectSelect.appendChild(option);
});
})
.catch(() => {});
}
function goBack() {
window.history.back();
}
function savePortfolio() {
const techStackStr = document.getElementById('portfolioTechStack').value;
const techStack = techStackStr ? techStackStr.split(',').map(s => s.trim()).filter(s => s) : [];
const portfolioId = document.getElementById('portfolioId').value;
const projectIdVal = document.getElementById('portfolioProjectId').value;
const data = {
id: portfolioId ? parseInt(portfolioId) : 0,
user_id: document.getElementById('portfolioUserId').value,
name: document.getElementById('portfolioName').value,
description: document.getElementById('portfolioDescription').value,
tech_stack: techStack,
url: document.getElementById('portfolioUrl').value,
start_date: document.getElementById('portfolioStartDate').value,
end_date: document.getElementById('portfolioEndDate').value,
doc: simplemde.value(),
show_in_resume: document.getElementById('portfolioShowInResume').checked,
resume_id: document.getElementById('portfolioProjectResumeId').value,
project_id: projectIdVal ? parseInt(projectIdVal) : 0,
password: document.getElementById('portfolioPassword').value,
hidden: document.getElementById('portfolioHidden').checked
};
if (!data.user_id) {
showCustomAlert('error', '错误', '请选择人员');
return;
}
if (!data.name) {
showCustomAlert('error', '错误', '请输入项目名称');
return;
}
const isEdit = parseInt(portfolioId) > 0;
const url = isEdit ? '/dashboard/api/portfolio/' + portfolioId : '/dashboard/api/portfolio';
const method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', isEdit ? '项目更新成功' : '项目创建成功');
setTimeout(() => goBack(), 1500);
} else {
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '保存失败');
});
}
</script>
+321
View File
@@ -0,0 +1,321 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-folder-open text-purple-500"></i> 作品集管理
</h3>
<p class="text-sm text-slate-500 mt-1">管理所有用户的作品集项目</p>
</div>
<a href="{{$.AdminPath}}/portfolio/add" class="btn btn-primary">
<i class="fas fa-plus"></i> 新增项目
</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">项目名称</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">用户</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">链接</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">隐藏状态</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">首页展示</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600 actions-col">操作</th>
</tr>
</thead>
<tbody>
{{ range .Items }}
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-slate-800">{{.Name}}</span>
</td>
<td class="px-6 py-4 text-slate-600">
{{ if .UserID }}
{{ $user := index $.UserMap .UserID }}
{{ if $user }}
{{$user.Name}}{{$user.Username}}
{{ else }}
<span class="text-slate-400">已失效</span>
{{ end }}
{{ else }}
<span class="text-slate-400">未分配</span>
{{ end }}
</td>
<td class="px-6 py-4 text-slate-600">
{{ if .URL }}
<a href="{{.URL}}" target="_blank" class="text-blue-600 hover:underline">点击访问</a>
{{ else }}
-
{{ end }}
</td>
<td class="px-6 py-4">
{{ if .Hidden }}
<span class="badge badge-danger">已隐藏</span>
{{ else }}
<span class="badge badge-success">显示</span>
{{ end }}
</td>
<td class="px-6 py-4">
{{ if .ShowInResume }}
<span class="badge badge-success"></span>
{{ else }}
<span class="badge badge-slate"></span>
{{ end }}
</td>
<td class="px-6 py-4 actions-col">
<div class="btn-group">
<a href="{{$.AdminPath}}/portfolio/{{.ID}}/edit" class="btn btn-warning btn-xs" title="编辑">
<i class="fas fa-edit"></i> 编辑
</a>
<button onclick="deletePortfolio('{{.ID}}')" class="btn btn-danger btn-xs" title="删除">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</td>
</tr>
{{ else }}
<tr>
<td colspan="6" class="px-6 py-12 text-center text-slate-500">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-folder-open text-slate-400 text-2xl"></i>
</div>
<p>暂无作品集项目</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if gt .TotalPages 1 }}
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
<div class="text-sm text-slate-500">共 {{ .Total }} 条记录,第 {{ .Page }} / {{ .TotalPages }} 页</div>
<div class="flex items-center gap-2">
<a href="?page=1" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
<i class="fas fa-chevron-left"></i>
</a>
<a href="?page={{ if eq .Page 1 }}1{{ else }}{{ subtract .Page 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
</a>
{{ range $i := to 1 $.TotalPages }}
{{ if or (eq $i 1) (eq $i $.TotalPages) (and (ge $i (subtract $.Page 2)) (le $i (add $.Page 2))) }}
<a href="?page={{ $i }}" class="btn btn-sm {{ if eq $i $.Page }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
{{ else if and (eq $i 2) (gt (subtract $.Page 3) 1) }}
<span class="text-slate-400 px-2">...</span>
{{ else if and (eq $i (subtract $.TotalPages 1)) (lt (add $.Page 3) $.TotalPages) }}
<span class="text-slate-400 px-2">...</span>
{{ end }}
{{ end }}
<a href="?page={{ if eq .Page .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .Page 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
</a>
<a href="?page={{ .TotalPages }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
<i class="fas fa-chevron-right"></i>
</a>
</div>
</div>
{{ end }}
</div>
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="confirmModal">
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-sm mx-4">
<div class="flex items-center justify-between p-4 border-b border-slate-200">
<h3 class="font-semibold text-slate-800">确认删除</h3>
<button onclick="closeConfirmModal()" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-500"></i>
</div>
<p class="text-slate-700">确定要删除这个项目吗?此操作不可撤销。</p>
</div>
<div class="flex gap-3">
<button onclick="closeConfirmModal()" class="flex-1 btn btn-secondary">取消</button>
<button onclick="confirmDeleteAction()" class="flex-1 btn btn-danger">确认删除</button>
</div>
</div>
</div>
</div>
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="portfolioModal">
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
<div class="flex items-center justify-between p-4 border-b border-slate-200">
<h3 class="font-semibold text-slate-800" id="portfolioModalTitle">新增项目</h3>
<button onclick="closePortfolioModal()" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6">
<form id="portfolioForm" onsubmit="savePortfolio(event)">
<input type="hidden" id="portfolioId">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">选择人员</label>
<select id="portfolioUserId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
<option value="">请选择人员</option>
{{ range .Users }}
<option value="{{.ID}}">{{.Name}} - {{.Email}}</option>
{{ end }}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目名称</label>
<input type="text" id="portfolioName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目描述</label>
<textarea id="portfolioDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">技术栈 (每行一个)</label>
<textarea id="portfolioTechStack" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="Go&#10;React&#10;MySQL"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目链接</label>
<input type="url" id="portfolioURL" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
<input type="date" id="portfolioStartDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
<input type="date" id="portfolioEndDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2">
<input type="checkbox" id="portfolioHidden" class="rounded border-slate-300 text-primary-600 focus:ring-primary-500">
<span class="text-sm text-slate-700">隐藏项目</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" id="portfolioShowInResume" class="rounded border-slate-300 text-primary-600 focus:ring-primary-500">
<span class="text-sm text-slate-700">首页展示</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">访问密码 (可选)</label>
<input type="text" id="portfolioPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="留空则无需密码">
</div>
</div>
<div class="mt-6 flex gap-3">
<button type="button" onclick="closePortfolioModal()" class="flex-1 btn btn-secondary">取消</button>
<button type="submit" class="flex-1 btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openAddPortfolioModal() {
document.getElementById('portfolioModalTitle').textContent = '新增项目';
document.getElementById('portfolioForm').reset();
document.getElementById('portfolioId').value = '';
const modal = document.getElementById('portfolioModal');
modal.classList.remove('hidden');
modal.classList.add('active');
}
function closePortfolioModal() {
const modal = document.getElementById('portfolioModal');
modal.classList.add('hidden');
modal.classList.remove('active');
}
function editPortfolio(id) {
fetch('/api/portfolio/' + id)
.then(response => response.json())
.then(data => {
if (data.success) {
const item = data.data;
document.getElementById('portfolioModalTitle').textContent = '编辑项目';
document.getElementById('portfolioId').value = item.id;
document.getElementById('portfolioUserId').value = item.user_id;
document.getElementById('portfolioName').value = item.name;
document.getElementById('portfolioDescription').value = item.description || '';
document.getElementById('portfolioTechStack').value = (item.tech_stack || []).join('\n');
document.getElementById('portfolioURL').value = item.url || '';
document.getElementById('portfolioStartDate').value = item.start_date || '';
document.getElementById('portfolioEndDate').value = item.end_date || '';
document.getElementById('portfolioHidden').checked = item.hidden || false;
document.getElementById('portfolioShowInResume').checked = item.show_in_resume || false;
document.getElementById('portfolioPassword').value = item.password || '';
const modal = document.getElementById('portfolioModal');
modal.classList.remove('hidden');
modal.classList.add('active');
}
});
}
function savePortfolio(event) {
event.preventDefault();
const id = document.getElementById('portfolioId').value;
const url = id ? '/api/portfolio/' + id : '/api/portfolio';
const method = id ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: document.getElementById('portfolioUserId').value,
name: document.getElementById('portfolioName').value,
description: document.getElementById('portfolioDescription').value,
tech_stack: document.getElementById('portfolioTechStack').value.split('\n').filter(s => s.trim()),
url: document.getElementById('portfolioURL').value,
start_date: document.getElementById('portfolioStartDate').value,
end_date: document.getElementById('portfolioEndDate').value,
hidden: document.getElementById('portfolioHidden').checked,
show_in_resume: document.getElementById('portfolioShowInResume').checked,
password: document.getElementById('portfolioPassword').value,
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
closePortfolioModal();
location.reload();
} else {
showToast(data.message || '保存失败', 'error');
}
});
}
var deleteTargetId = null;
function deletePortfolio(id) {
deleteTargetId = id;
const modal = document.getElementById('confirmModal');
modal.classList.remove('hidden');
modal.classList.add('active');
}
function closeConfirmModal() {
deleteTargetId = null;
const modal = document.getElementById('confirmModal');
modal.classList.add('hidden');
modal.classList.remove('active');
}
function confirmDeleteAction() {
if (!deleteTargetId) return;
fetch('/api/portfolio/' + deleteTargetId, {
method: 'DELETE',
})
.then(response => response.json())
.then(data => {
closeConfirmModal();
if (data.success) {
location.reload();
} else {
showToast(data.message || '删除失败', 'error');
}
});
}
</script>
+482
View File
@@ -0,0 +1,482 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-robot text-orange-500"></i> AI生成题目
</h3>
<p class="text-sm text-slate-500 mt-1">基于简历或关键词智能生成面试题目</p>
</div>
<div class="btn-group">
<button onclick="goBack()" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left"></i> 返回
</button>
</div>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-1">
<div class="bg-slate-50 rounded-lg p-4 sticky top-6">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-sliders-h text-primary-500"></i> 生成设置
</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">选择简历(可选)</label>
<select id="quizResumeId" class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm">
<option value="">不选择简历(使用自定义关键词)</option>
{{ range .Resumes }}
<option value="{{.ID}}">{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</option>
{{ end }}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">自定义关键词(可选)</label>
<textarea id="quizKeywords" rows="3" class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm resize-none" placeholder="如:Go,MySQL,微服务,Redis&#10;不选简历时必填"></textarea>
</div>
<button onclick="doGenerateQuestions()" id="generateBtn" class="w-full btn btn-primary">
<i class="fas fa-robot"></i> 生成题目
</button>
</div>
</div>
</div>
<div class="lg:col-span-3">
<div id="questionsContainer" class="hidden">
<div class="bg-slate-50 rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h4 class="font-medium text-slate-700 flex items-center gap-2">
<i class="fas fa-question-circle text-primary-500"></i> 请回答以下问题
</h4>
<span class="text-sm text-slate-500"><span id="questionCount">0</span></span>
</div>
<div id="questionsList" class="space-y-6"></div>
<div class="mt-6 flex gap-3">
<button onclick="goBack()" class="flex-1 btn btn-secondary">取消</button>
<button onclick="submitAnswers()" class="flex-1 btn btn-primary">提交答案</button>
</div>
</div>
</div>
<div id="emptyState" class="bg-slate-50 rounded-lg p-12 text-center">
<div class="w-20 h-20 mx-auto mb-4 rounded-full bg-orange-100 flex items-center justify-center">
<i class="fas fa-robot text-orange-500 text-4xl"></i>
</div>
<h4 class="font-medium text-slate-700 mb-2">开始生成题目</h4>
<p class="text-sm text-slate-500">选择简历或输入关键词,点击生成按钮开始</p>
</div>
</div>
</div>
</div>
</div>
<input type="hidden" id="user-id" value="{{ with .User }}{{.ID}}{{ end }}">
<script>
var currentQuestions = [];
function goBack() {
window.history.back();
}
function doGenerateQuestions() {
const resumeId = document.getElementById('quizResumeId').value;
const keywords = document.getElementById('quizKeywords').value.trim();
if (!resumeId && !keywords) {
showToast('请选择简历或输入关键词', 'error');
return;
}
const btn = document.getElementById('generateBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 生成中...';
fetch('/api/ai/agent/generate-questions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
resume_id: resumeId,
keywords: keywords,
})
})
.then(response => response.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-robot"></i> 生成题目';
const resultData = data.data || data;
if (data.success && resultData.questions && resultData.questions.length > 0) {
currentQuestions = resultData.questions;
renderQuestions(resultData.questions);
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('questionsContainer').classList.remove('hidden');
} else {
showToast(data.message || '生成题目失败', 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-robot"></i> 生成题目';
showToast('生成题目失败', 'error');
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderQuestions(questions) {
const container = document.getElementById('questionsList');
const countSpan = document.getElementById('questionCount');
container.innerHTML = '';
countSpan.textContent = questions.length;
questions.forEach((q, index) => {
const qDiv = document.createElement('div');
qDiv.className = 'bg-white p-5 rounded-lg border border-slate-200 shadow-sm';
let typeLabel = '';
let typeColor = '';
switch(q.type) {
case 'mcq': typeLabel = '选择题'; typeColor = 'bg-blue-100 text-blue-700'; break;
case 'fill': typeLabel = '填空题'; typeColor = 'bg-purple-100 text-purple-700'; break;
case 'sa': typeLabel = '简答题'; typeColor = 'bg-green-100 text-green-700'; break;
case 'algo': typeLabel = '算法题'; typeColor = 'bg-orange-100 text-orange-700'; break;
default: typeLabel = '问答题'; typeColor = 'bg-gray-100 text-gray-700';
}
const category = q.category || '综合能力';
const difficulty = q.difficulty || '初级';
const hints = q.hints || [];
let hintsHtml = '';
if (hints.length > 0) {
hintsHtml = '<div class="mt-3 p-3 bg-yellow-50 rounded-lg border border-yellow-200">' +
'<div class="flex items-center gap-2 text-sm font-medium text-yellow-700 mb-2">' +
'<i class="fas fa-lightbulb"></i> 回答要点提示</div>' +
'<ul class="text-xs text-yellow-600 space-y-1">';
hints.forEach(h => {
hintsHtml += '<li class="flex items-start gap-2"><span class="text-yellow-500">•</span>' + escapeHtml(h) + '</li>';
});
hintsHtml += '</ul></div>';
}
let optionsHtml = '';
if (q.options && q.options.length > 0) {
optionsHtml = '<div class="space-y-2 mt-3">';
q.options.forEach((opt, optIndex) => {
const letter = String.fromCharCode(65 + optIndex);
optionsHtml +=
'<label class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 cursor-pointer border border-slate-100 transition-colors">' +
'<input type="radio" name="q' + index + '" value="' + letter + '" class="w-4 h-4 text-primary-600">' +
'<span class="text-sm text-slate-700">' + letter + '. ' + escapeHtml(opt) + '</span>' +
'</label>';
});
optionsHtml += '</div>';
} else if (q.type === 'fill') {
optionsHtml =
'<div class="mt-3">' +
'<input type="text" id="ans' + index + '" class="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm" placeholder="请输入答案" autocomplete="off" >' +
'</div>';
} else {
optionsHtml =
'<div class="mt-3">' +
'<textarea id="ans' + index + '" rows="4" class="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm resize-none" placeholder="请输入您的回答"></textarea>' +
'</div>';
}
qDiv.innerHTML =
'<div class="flex flex-wrap items-center justify-between mb-3">' +
'<div class="flex flex-wrap items-center gap-2">' +
'<span class="px-2.5 py-1 ' + typeColor + ' rounded-full text-xs font-medium">' + typeLabel + '</span>' +
'<span class="px-2.5 py-1 ' + getDifficultyColor(difficulty) + ' rounded-full text-xs font-medium">' + difficulty + '</span>' +
'<span class="px-2.5 py-1 bg-indigo-100 text-indigo-700 rounded-full text-xs font-medium">' + category + '</span>' +
'<span class="text-xs text-slate-400">第 ' + (index + 1) + ' 题 · ' + q.score + ' 分</span>' +
'</div>' +
'<button onclick="toggleQuestionFavorite(' + index + ')" id="favBtn' + index + '" class="btn btn-xs btn-yellow flex items-center gap-1 hover:bg-yellow-100 transition-colors" title="收藏本题">' +
'<i class="fas fa-star"></i> 收藏' +
'</button>' +
'</div>' +
'<p class="text-slate-800 font-medium leading-relaxed">' + escapeHtml(q.text) + '</p>' +
optionsHtml +
hintsHtml;
container.appendChild(qDiv);
});
}
var favoriteQuestions = [];
function toggleQuestionFavorite(index) {
const q = currentQuestions[index];
const btn = document.getElementById(`favBtn${index}`);
const idx = favoriteQuestions.indexOf(index);
if (idx > -1) {
favoriteQuestions.splice(idx, 1);
btn.classList.remove('bg-yellow-100', 'text-yellow-700');
btn.innerHTML = '<i class="fas fa-star"></i> 收藏';
} else {
favoriteQuestions.push(index);
btn.classList.add('bg-yellow-100', 'text-yellow-700');
btn.innerHTML = '<i class="fas fa-star"></i> 已收藏';
showToast('题目已收藏', 'success');
}
}
function submitAnswers() {
const answers = {};
const unanswered = [];
currentQuestions.forEach((q, index) => {
let answer = '';
if (q.type === 'mcq') {
const radios = document.getElementsByName('q' + index);
for (let radio of radios) {
if (radio.checked) {
answer = radio.value;
break;
}
}
} else {
const input = document.getElementById('ans' + index);
answer = input ? input.value.trim() : '';
}
if (!answer) {
unanswered.push(index + 1);
}
answers['q' + index] = answer;
});
if (unanswered.length > 0) {
const unansweredList = unanswered.join('、');
showCustomConfirm(
'确认提交',
`您还有 ${unanswered.length} 道题未作答:第 ${unansweredList} 题。\n\n确定要继续提交吗?未作答的题目将不计分。`,
function(confirmed) {
if (confirmed) {
doSubmit(answers);
}
}
);
return;
}
doSubmit(answers);
}
function doSubmit(answers) {
let score = 0;
let correctCount = 0;
let unansweredCount = 0;
currentQuestions.forEach((q, index) => {
const userAnswer = answers['q' + index];
if (!userAnswer) {
unansweredCount++;
return;
}
if (isAnswerCorrect(q, userAnswer)) {
score += q.score;
correctCount++;
}
});
const resultData = {
questions: currentQuestions,
user_answers: answers,
score: score,
correct_count: correctCount,
total_count: currentQuestions.length,
unanswered_count: unansweredCount
};
fetch('/api/ai/agent/submit-answers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(resultData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
saveQuizRecord(answers, score, correctCount, currentQuestions.length, unansweredCount);
} else {
showToast(data.error || '提交失败', 'error');
}
})
.catch(() => {
showToast('提交失败', 'error');
});
}
function isAnswerCorrect(question, userAnswer) {
const userAns = userAnswer.trim().toLowerCase();
const correctAns = String(question.answer).trim().toLowerCase();
if (question.type === 'mcq') {
return userAns === correctAns;
}
if (question.type === 'fill') {
return userAns === correctAns;
}
if (question.type === 'sa' || question.type === 'algo') {
return userAns.length > 0;
}
return userAns === correctAns;
}
function saveQuizRecord(answers, score, correctCount, totalCount, unansweredCount) {
const resumeId = document.getElementById('quizResumeId').value;
const keywords = document.getElementById('quizKeywords').value.trim();
const recordId = 'quiz-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
const userAnswers = {};
currentQuestions.forEach((q, index) => {
userAnswers[index] = answers['q' + index] || '';
});
const now = new Date();
const title = now.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const userId = document.getElementById('user-id').value;
const recordData = {
id: recordId,
user_id: userId,
resume_id: resumeId,
resume_route: resumeId ? '' : 'custom-quiz',
title: '答题记录 - ' + title,
questions: currentQuestions.map(q => ({
type: q.type,
text: q.text,
options: q.options || [],
answer: q.answer,
analysis: q.analysis || '',
score: q.score,
keywords: q.keywords || []
})),
user_answers: userAnswers,
score: score,
correct_count: correctCount,
total_count: totalCount
};
fetch('/api/quiz/record', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(recordData)
})
.then(response => response.json())
.then(data => {
try {
saveFavoriteAndWrongQuestions(resumeId, answers);
} catch (e) {
console.error('Failed to save favorite/wrong questions:', e);
}
let message = `答题完成!`;
if (unansweredCount > 0) {
message += `\n得分:${score}分(${correctCount}/${totalCount - unansweredCount}正确,${unansweredCount}题未作答)`;
} else {
message += `\n得分:${score}分(${correctCount}/${totalCount}正确)`;
}
showToast(message, 'success');
setTimeout(() => {
const url = new URL(window.location.href);
url.pathname = url.pathname.replace('/quiz/generate', '/quiz');
url.searchParams.set('record', recordId);
window.location.href = url.toString();
}, 2000);
})
.catch(() => {
let message = `答题完成!`;
if (unansweredCount > 0) {
message += `\n得分:${score}分(${correctCount}/${totalCount - unansweredCount}正确,${unansweredCount}题未作答)`;
} else {
message += `\n得分:${score}分(${correctCount}/${totalCount}正确)`;
}
showToast(message, 'success');
setTimeout(() => {
const url = new URL(window.location.href);
url.pathname = url.pathname.replace('/quiz/generate', '/quiz');
url.searchParams.set('record', recordId);
window.location.href = url.toString();
}, 2000);
});
}
function saveFavoriteAndWrongQuestions(resumeId, answers) {
const userId = document.getElementById('user-id').value;
currentQuestions.forEach((q, index) => {
const userAnswer = answers['q' + index];
const isCorrect = userAnswer && isAnswerCorrect(q, userAnswer);
const isFavorite = favoriteQuestions.includes(index);
if (!isCorrect || isFavorite) {
const favData = {
id: generateUUID(),
user_id: userId,
resume_id: resumeId,
resume_route: '',
question: {
type: q.type,
text: q.text,
options: q.options || [],
answer: q.answer,
analysis: q.analysis || '',
score: q.score,
keywords: q.keywords || [],
difficulty: q.difficulty || '初级',
category: q.category || '综合能力'
},
user_answer: userAnswer || '',
is_correct: isCorrect,
is_favorite: isFavorite
};
fetch('/api/favorite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(favData)
}).catch(() => {});
}
});
}
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function getDifficultyColor(difficulty) {
switch(difficulty) {
case '入门': return 'bg-green-100 text-green-700';
case '初级': return 'bg-blue-100 text-blue-700';
case '中级': return 'bg-yellow-100 text-yellow-700';
case '进阶': return 'bg-orange-100 text-orange-700';
case '高级': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-700';
}
}
</script>
+270
View File
@@ -0,0 +1,270 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<a href="{{.AdminPath}}/user/{{.User.ID}}/quiz?tab=records" class="text-slate-400 hover:text-slate-600 transition-colors">
<i class="fas fa-arrow-left"></i>
</a>
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-file-alt text-blue-500"></i> {{ if .Record.Title }}{{.Record.Title}}{{ else }}答题详情{{ end }}
</h3>
<p class="text-sm text-slate-500 mt-1">{{ if .User.Name }}{{.User.Name}}{{ else }}{{.User.Username}}{{ end }} 的答题记录</p>
</div>
</div>
<div class="flex items-center gap-6">
<div class="text-center">
<p class="text-xs text-slate-500 mb-1">答题时间</p>
<p class="text-sm font-medium text-slate-700">{{.Record.CreatedAt}}</p>
</div>
<div class="text-center">
<p class="text-xs text-slate-500 mb-1">得分</p>
<p class="text-3xl font-bold {{ if ge .Record.Score 60.0 }}text-green-600{{ else }}text-red-600{{ end }}">{{.Record.Score}}<span class="text-sm font-normal text-slate-400 ml-1"></span></p>
</div>
<div class="text-center">
<p class="text-xs text-slate-500 mb-1">正确率</p>
<p class="text-sm font-medium text-slate-700">{{.Record.CorrectCount}}/{{.Record.TotalCount}} 题</p>
</div>
</div>
</div>
</div>
<div class="p-6">
<div class="space-y-4">
{{ range $index, $q := .Questions }}
<div class="border border-slate-200 rounded-xl overflow-hidden" data-question-index="{{$index}}">
<div class="flex items-center justify-between px-4 py-3 bg-slate-50 border-b border-slate-200">
<div class="flex items-center gap-3">
<span class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-sm font-medium text-slate-600">{{ add $index 1 }}</span>
<div class="flex items-center gap-2">
{{ if eq $q.Type "mcq" }}<span class="px-2 py-0.5 bg-blue-100 text-blue-600 rounded text-xs">选择题</span>{{ end }}
{{ if eq $q.Type "fill" }}<span class="px-2 py-0.5 bg-green-100 text-green-600 rounded text-xs">填空题</span>{{ end }}
{{ if eq $q.Type "sa" }}<span class="px-2 py-0.5 bg-purple-100 text-purple-600 rounded text-xs">简答题</span>{{ end }}
{{ if eq $q.Type "algo" }}<span class="px-2 py-0.5 bg-orange-100 text-orange-600 rounded text-xs">算法题</span>{{ end }}
{{ if $q.Difficulty }}
<span class="px-2 py-0.5 rounded text-xs"
{{ if eq $q.Difficulty "入门" }}style="background-color: #dcfce7; color: #166534;"{{ end }}
{{ if eq $q.Difficulty "初级" }}style="background-color: #dbeafe; color: #1e40af;"{{ end }}
{{ if eq $q.Difficulty "中级" }}style="background-color: #fef3c7; color: #92400e;"{{ end }}
{{ if eq $q.Difficulty "进阶" }}style="background-color: #ffedd5; color: #c2410c;"{{ end }}
{{ if eq $q.Difficulty "高级" }}style="background-color: #fee2e2; color: #991b1b;"{{ end }}>
{{ $q.Difficulty }}
</span>
{{ end }}
{{ if $q.Category }}<span class="px-2 py-0.5 bg-indigo-100 text-indigo-600 rounded text-xs">{{$q.Category}}</span>{{ end }}
</div>
</div>
<div class="flex items-center gap-3">
<button class="toggle-favorite-btn flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm transition-all hover:bg-slate-100"
data-index="{{$index}}" data-user-id="{{$.User.ID}}" data-resume-id="{{$.Record.ResumeID}}" data-resume-route="{{$.Record.ResumeRoute}}">
<i class="fas fa-star text-slate-400"></i>
<span class="text-slate-500">收藏</span>
</button>
<span class="px-2 py-1 rounded-full text-xs font-medium {{ if index $.Correct $index }}bg-green-100 text-green-700{{ else }}bg-red-100 text-red-700{{ end }}">
{{ if index $.Correct $index }}回答正确{{ else }}回答错误{{ end }}
</span>
</div>
</div>
<div class="p-5">
<p class="text-slate-800 font-medium mb-4">{{ $q.Text }}</p>
{{ if $q.Options }}
<div class="space-y-2 mb-4">
{{ range $optIndex, $opt := $q.Options }}
{{ $letter := printf "%c" (index "ABCDE" $optIndex) }}
{{ $isCorrect := eq $letter $q.Answer }}
{{ $userAnswer := index $.UserAnswers $index }}
{{ $isUserAns := eq $letter $userAnswer }}
<div class="flex items-center gap-3 p-3 rounded-lg {{ if $isCorrect }}bg-green-50 border border-green-200{{ else if and $isUserAns (not $isCorrect) }}bg-red-50 border border-red-200{{ else }}bg-slate-50{{ end }}">
<span class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium {{ if $isCorrect }}bg-green-500 text-white{{ else if and $isUserAns (not $isCorrect) }}bg-red-500 text-white{{ else }}bg-slate-200 text-slate-600{{ end }}">{{ $letter }}</span>
<span class="text-sm {{ if $isCorrect }}text-green-700 font-medium{{ else if and $isUserAns (not $isCorrect) }}text-red-700{{ else }}text-slate-600{{ end }}">{{ $opt }}</span>
{{ if $isCorrect }}<i class="fas fa-check text-green-500 ml-auto text-sm"></i>{{ end }}
{{ if and $isUserAns (not $isCorrect) }}<i class="fas fa-times text-red-500 ml-auto text-sm"></i>{{ end }}
</div>
{{ end }}
</div>
{{ end }}
<div class="flex items-center gap-6 mb-4 text-sm">
<div class="flex items-center gap-2">
<span class="text-slate-500">正确答案:</span>
<span class="font-medium text-green-600 markdown-answer">{{ $q.Answer }}</span>
</div>
{{ with index $.UserAnswers $index }}
<div class="flex items-center gap-2">
<span class="text-slate-500">你的答案:</span>
<span class="font-medium {{ if index $.Correct $index }}text-green-600{{ else }}text-red-600{{ end }}">{{ . }}</span>
</div>
{{ end }}
</div>
{{ if $q.Analysis }}
<div class="p-4 bg-blue-50 rounded-lg border border-blue-100">
<div class="font-medium text-blue-700 text-sm mb-2 flex items-center gap-2">
<i class="fas fa-lightbulb text-xs"></i> 答案解析
</div>
<div class="text-sm text-slate-600 markdown-body markdown-analysis">{{ $q.Analysis }}</div>
</div>
{{ end }}
</div>
</div>
{{ end }}
</div>
</div>
</div>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.0/github-markdown.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<style>
.markdown-body { background-color: transparent !important; font-size: 0.88rem; }
.markdown-body p { margin: 0.5em 0; }
.markdown-body pre { background: #1e293b; padding: 0.75rem; border-radius: 0.38rem; overflow-x: auto; }
.markdown-body code { background: rgba(0,0,0,0.06); padding: 0.12rem 0.38rem; border-radius: 0.25rem; font-size: 0.81rem; }
.markdown-body pre code { background: transparent; padding: 0; color: #e2e8f0; }
.toggle-favorite-btn.favorited i { color: #fbbf24; }
.toggle-favorite-btn.favorited span { color: #d97706; }
.toggle-favorite-btn.favorited { background-color: #fef3c7; }
</style>
<script>
function decodeUnicode(str) {
return str.replace(/\\u([0-9a-fA-F]{4})/g, function (_, hex) {
return String.fromCharCode(parseInt(hex, 16));
});
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
document.addEventListener('DOMContentLoaded', function () {
marked.setOptions({ breaks: true, gfm: true });
document.querySelectorAll('.markdown-analysis').forEach(function (el) {
let md = decodeUnicode(el.textContent || '');
if (!md.trim()) return;
md = escapeHtml(md);
el.innerHTML = DOMPurify.sanitize(marked.parse(md));
el.classList.add('markdown-body');
});
document.querySelectorAll('.markdown-answer').forEach(function (el) {
let md = decodeUnicode(el.textContent || '');
if (!md.trim()) return;
md = escapeHtml(md);
el.innerHTML = DOMPurify.sanitize(marked.parseInline(md));
});
document.querySelectorAll('.toggle-favorite-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const index = parseInt(this.dataset.index);
const userId = this.dataset.userId;
const resumeId = this.dataset.resumeId;
const resumeRoute = this.dataset.resumeRoute;
const questionEl = this.closest('[data-question-index]');
const questionType = questionEl.querySelector('[class*="bg-blue-100"], [class*="bg-green-100"], [class*="bg-purple-100"], [class*="bg-orange-100"]')?.textContent || '综合';
const questionText = questionEl.querySelector('.text-slate-800.font-medium')?.textContent || '';
const questionOptions = [];
questionEl.querySelectorAll('.space-y-2 .text-sm').forEach(function(opt) {
questionOptions.push(opt.textContent.trim());
});
const questionAnswer = questionEl.querySelector('.text-green-600.markdown-answer')?.textContent || '';
const questionAnalysis = questionEl.querySelector('.markdown-analysis')?.textContent || '';
const questionDifficulty = questionEl.querySelector('[style*="background-color"]')?.textContent || '初级';
const questionCategory = questionEl.querySelector('.bg-indigo-100')?.textContent || '综合';
const isCorrectEl = this.nextElementSibling;
const isCorrect = isCorrectEl.textContent.includes('正确');
const userAnswerEl = questionEl.querySelector('.text-red-600') || questionEl.querySelector('.text-green-600');
const userAnswer = userAnswerEl ? userAnswerEl.textContent.trim().replace('你的答案:', '') : '';
const questionData = {
type: questionType.includes('选择') ? 'mcq' :
questionType.includes('填空') ? 'fill' :
questionType.includes('简答') ? 'sa' : 'algo',
text: questionText,
options: questionOptions.length > 0 ? questionOptions : null,
answer: questionAnswer,
analysis: questionAnalysis,
score: 10,
keywords: [],
difficulty: questionDifficulty,
category: questionCategory
};
const payload = {
user_id: userId,
resume_id: resumeId,
resume_route: resumeRoute,
question: questionData,
user_answer: userAnswer,
is_correct: isCorrect,
is_favorite: true
};
fetch('/api/quiz/favorite', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (data.success || data.code === 201) {
this.classList.add('favorited');
this.querySelector('i').classList.remove('text-slate-400');
this.querySelector('i').classList.add('text-yellow-500');
this.querySelector('span').textContent = '已收藏';
showToast('收藏成功');
} else {
showToast('收藏失败: ' + (data.message || '未知错误'));
}
})
.catch(error => {
showToast('收藏失败: ' + error.message);
});
});
});
});
function showToast(message) {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
top: 1.25rem;
right: 1.25rem;
padding: 0.75rem 1.50rem;
background: rgba(0,0,0,0.8);
color: white;
border-radius: 0.50rem;
z-index: 1000;
font-size: 0.88rem;
animation: slideIn 0.3s ease;
`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
</script>
+220
View File
@@ -0,0 +1,220 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-question-circle text-purple-500"></i> 面试答题管理
</h3>
<p class="text-sm text-slate-500 mt-1">管理用户的面试题库、答题记录和学习进度</p>
</div>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-5 border border-blue-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-blue-600 font-medium">总用户数</p>
<p class="text-2xl font-bold text-blue-800 mt-1">{{ len .UsersWithStats }}</p>
</div>
<div class="w-12 h-12 rounded-full bg-blue-200 flex items-center justify-center">
<i class="fas fa-users text-blue-600"></i>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-5 border border-green-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-green-600 font-medium">总答题次数</p>
<p class="text-2xl font-bold text-green-800 mt-1">{{ .TotalStats.TotalAttempts }}</p>
</div>
<div class="w-12 h-12 rounded-full bg-green-200 flex items-center justify-center">
<i class="fas fa-check-circle text-green-600"></i>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-xl p-5 border border-yellow-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-yellow-600 font-medium">收藏题目</p>
<p class="text-2xl font-bold text-yellow-800 mt-1">{{ .TotalStats.TotalFavorites }}</p>
</div>
<div class="w-12 h-12 rounded-full bg-yellow-200 flex items-center justify-center">
<i class="fas fa-star text-yellow-600"></i>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-red-50 to-red-100 rounded-xl p-5 border border-red-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-red-600 font-medium">错题数量</p>
<p class="text-2xl font-bold text-red-800 mt-1">{{ .TotalStats.TotalWrong }}</p>
</div>
<div class="w-12 h-12 rounded-full bg-red-200 flex items-center justify-center">
<i class="fas fa-times-circle text-red-600"></i>
</div>
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">用户信息</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">答题统计</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">正确率</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">收藏/错题</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">最近答题</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">操作</th>
</tr>
</thead>
<tbody>
{{ range .UsersWithStats }}
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-md">
{{ if .User.Name }}{{ substr .User.Name 0 1 }}{{ else }}{{ substr .User.Username 0 1 }}{{ end }}
</div>
<div>
<p class="font-medium text-slate-800">{{ if .User.Name }}{{ .User.Name }}{{ else }}{{ .User.Username }}{{ end }}</p>
<p class="text-sm text-slate-500">{{ .User.Email }}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-4">
<div>
<p class="text-xs text-slate-500">答题次数</p>
<p class="font-semibold text-slate-800">{{ .TotalAttempts }}</p>
</div>
<div class="w-px h-10 bg-slate-200"></div>
<div>
<p class="text-xs text-slate-500">平均得分</p>
<p class="font-semibold text-slate-800">{{ printf "%.1f" .AvgScore }}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-20 h-3 bg-slate-100 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all"
style="width: {{ .CorrectRate }}%; background-color: {{ if ge .CorrectRate 80 }}#22c55e{{ else if ge .CorrectRate 60 }}#eab308{{ else }}#ef4444{{ end }}"></div>
</div>
<span class="text-sm font-medium text-slate-700">{{ .CorrectRate }}%</span>
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1 text-yellow-600">
<i class="fas fa-star"></i>
<span class="text-sm font-medium">{{ .FavoriteCount }}</span>
</span>
<span class="flex items-center gap-1 text-red-500">
<i class="fas fa-times-circle"></i>
<span class="text-sm font-medium">{{ .WrongCount }}</span>
</span>
</div>
</td>
<td class="px-6 py-4">
<span class="text-sm text-slate-500">{{ if .LastAttemptDate }}{{ .LastAttemptDate }}{{ else }}暂无记录{{ end }}</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<a href="{{$.AdminPath}}/user/{{.User.ID}}/quiz/generate" class="btn btn-primary btn-xs" title="AI生成题目">
<i class="fas fa-robot"></i> 生成题目
</a>
<a href="{{$.AdminPath}}/user/{{.User.ID}}/quiz" class="btn btn-info btn-xs" title="查看题库">
<i class="fas fa-book-open"></i> 题库
</a>
</div>
</td>
</tr>
{{ else }}
<tr>
<td colspan="6" class="px-6 py-12 text-center text-slate-500">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-users text-slate-400 text-2xl"></i>
</div>
<p>暂无用户,请先添加用户</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if gt .TotalPages 1 }}
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
<div class="text-sm text-slate-500">共 {{ .TotalRecords }} 条记录,第 {{ .CurrentPage }} / {{ .TotalPages }} 页</div>
<div class="flex items-center gap-2">
<a href="?page=1" class="btn btn-sm btn-slate {{ if eq .CurrentPage 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
<i class="fas fa-chevron-left"></i>
</a>
<a href="?page={{ if eq .CurrentPage 1 }}1{{ else }}{{ subtract .CurrentPage 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .CurrentPage 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
</a>
{{ range $i := to 1 $.TotalPages }}
{{ if or (eq $i 1) (eq $i $.TotalPages) (and (ge $i (subtract $.CurrentPage 2)) (le $i (add $.CurrentPage 2))) }}
<a href="?page={{ $i }}" class="btn btn-sm {{ if eq $i $.CurrentPage }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
{{ else if and (eq $i 2) (gt (subtract $.CurrentPage 3) 1) }}
<span class="text-slate-400 px-2">...</span>
{{ else if and (eq $i (subtract $.TotalPages 1)) (lt (add $.CurrentPage 3) $.TotalPages) }}
<span class="text-slate-400 px-2">...</span>
{{ end }}
{{ end }}
<a href="?page={{ if eq .CurrentPage .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .CurrentPage 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .CurrentPage .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
</a>
<a href="?page={{ .TotalPages }}" class="btn btn-sm btn-slate {{ if eq .CurrentPage .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
<i class="fas fa-chevron-right"></i>
</a>
</div>
</div>
{{ end }}
<div class="mt-6 bg-slate-50 rounded-xl p-5">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-chart-line text-purple-500"></i> 功能说明
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white p-4 rounded-lg border border-slate-200">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
<i class="fas fa-robot text-blue-600"></i>
</div>
<span class="font-medium text-slate-700">AI生成题目</span>
</div>
<p class="text-sm text-slate-500">基于简历内容或关键词智能生成面试题目,支持多种难度级别</p>
</div>
<div class="bg-white p-4 rounded-lg border border-slate-200">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-full bg-yellow-100 flex items-center justify-center">
<i class="fas fa-star text-yellow-600"></i>
</div>
<span class="font-medium text-slate-700">收藏题目</span>
</div>
<p class="text-sm text-slate-500">收藏重要题目,方便复习回顾,支持快速筛选查看</p>
</div>
<div class="bg-white p-4 rounded-lg border border-slate-200">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center">
<i class="fas fa-times-circle text-red-600"></i>
</div>
<span class="font-medium text-slate-700">错题本</span>
</div>
<p class="text-sm text-slate-500">自动记录答错的题目,支持针对性练习和错题回顾</p>
</div>
<div class="bg-white p-4 rounded-lg border border-slate-200">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<i class="fas fa-chart-bar text-green-600"></i>
</div>
<span class="font-medium text-slate-700">数据统计</span>
</div>
<p class="text-sm text-slate-500">查看答题记录、正确率、得分趋势等学习数据</p>
</div>
</div>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+661
View File
@@ -0,0 +1,661 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-file-alt text-purple-500"></i> 简历管理
</h3>
<p class="text-sm text-slate-500 mt-1">管理所有用户的简历</p>
</div>
<div class="flex gap-2">
<button onclick="openUploadDocumentModal()" class="btn btn-success">
<i class="fas fa-upload"></i> 上传文档生成简历
</button>
<a href="{{$.AdminPath}}/resume/add" class="btn btn-primary">
<i class="fas fa-plus"></i> 添加简历
</a>
</div>
</div>
</div>
<div class="px-6 py-4 border-b border-slate-100 bg-white">
<form id="filterForm" method="GET" action="{{$.AdminPath}}/resumes" class="flex flex-wrap items-center gap-3">
<div class="flex-1 min-w-[11.25rem]">
<input type="text" name="keyword" value="{{.FilterKeyword}}" placeholder="搜索姓名/路由..." class="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<select name="user_id" class="px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">全部人员</option>
{{ range .Users }}
<option value="{{.ID}}" {{ if eq $.FilterUserID .ID }}selected{{ end }}>{{.Name}}{{.Username}}</option>
{{ end }}
</select>
<select name="template" class="px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">全部模板</option>
<option value="modern" {{ if eq .FilterTemplate "modern" }}selected{{ end }}>现代商务</option>
<option value="classic" {{ if eq .FilterTemplate "classic" }}selected{{ end }}>传统经典</option>
<option value="tech" {{ if eq .FilterTemplate "tech" }}selected{{ end }}>科技极客</option>
<option value="sidebar" {{ if eq .FilterTemplate "sidebar" }}selected{{ end }}>经典侧边栏</option>
<option value="elegant" {{ if eq .FilterTemplate "elegant" }}selected{{ end }}>优雅轻奢</option>
<option value="fresh" {{ if eq .FilterTemplate "fresh" }}selected{{ end }}>清新自然</option>
<option value="minimalist" {{ if eq .FilterTemplate "minimalist" }}selected{{ end }}>极简风格</option>
<option value="professional" {{ if eq .FilterTemplate "professional" }}selected{{ end }}>专业商务</option>
</select>
<select name="show_in_home" class="px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">首页展示</option>
<option value="1" {{ if eq .FilterShowInHome "1" }}selected{{ end }}>已展示</option>
<option value="0" {{ if eq .FilterShowInHome "0" }}selected{{ end }}>未展示</option>
</select>
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-search"></i> 筛选
</button>
<a href="{{$.AdminPath}}/resumes" class="btn btn-secondary btn-sm">
<i class="fas fa-redo"></i> 重置
</a>
</form>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">姓名</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">职位</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">所属人员</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">访问路由</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">首页展示</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">更新时间</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600 actions-col">操作</th>
</tr>
</thead>
<tbody>
{{ range .Resumes }}
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-slate-800">{{.BasicInfo.Name}}</span>
</td>
<td class="px-6 py-4 text-slate-600">{{.BasicInfo.Title}}</td>
<td class="px-6 py-4">
{{ if .UserID }}
{{ $user := index $.UserMap .UserID }}
{{ if $user }}
<span class="badge badge-success">{{$user.Name}}{{$user.Username}}</span>
{{ else }}
<span class="badge badge-warning">已失效</span>
{{ end }}
{{ else }}
<span class="badge badge-danger">未绑定</span>
{{ end }}
</td>
<td class="px-6 py-4 text-slate-600">/{{.Route}}</td>
<td class="px-6 py-4">
{{ if .ShowInHome }}
<span class="badge badge-success"></span>
{{ else }}
<span class="badge badge-slate"></span>
{{ end }}
</td>
<td class="px-6 py-4 text-slate-500 text-sm">{{.UpdatedAt}}</td>
<td class="px-6 py-4 actions-col">
<div class="btn-group flex-wrap gap-1">
<a href="/{{.Route}}" target="_blank" class="btn btn-info btn-xs" title="预览">
<i class="fas fa-eye"></i> 预览
</a>
{{ if .UserID }}
<a href="{{$.AdminPath}}/user/{{.UserID}}/resume/{{.ID}}/edit" class="btn btn-primary btn-xs" title="编辑">
<i class="fas fa-edit"></i> 编辑
</a>
{{ else }}
<a href="{{$.AdminPath}}/resume/{{.ID}}/edit" class="btn btn-primary btn-xs" title="编辑">
<i class="fas fa-edit"></i> 编辑
</a>
{{ end }}
<button onclick="toggleShowInHome('{{.ID}}', {{.ShowInHome}})" class="btn btn-warning btn-xs" title="{{if .ShowInHome}}取消展示{{else}}展示{{end}}">
<i class="fas fa-home"></i> {{if .ShowInHome}}取消{{else}}展示{{end}}
</button>
<button onclick="updatePassword('{{.ID}}')" class="btn btn-secondary btn-xs" title="密码">
<i class="fas fa-lock"></i> 密码
</button>
{{ if .UserID }}
<button onclick="unbindUser('{{.ID}}')" class="btn btn-orange btn-xs" title="解绑人员">
<i class="fas fa-unlink"></i> 解绑
</button>
{{ else }}
<button onclick="bindUser('{{.ID}}')" class="btn btn-success btn-xs" title="绑定人员">
<i class="fas fa-link"></i> 绑定
</button>
{{ end }}
<button onclick="deleteResume('{{.ID}}')" class="btn btn-danger btn-xs" title="删除">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</td>
</tr>
{{ else }}
<tr>
<td colspan="7" class="px-6 py-12 text-center text-slate-500">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-file-alt text-slate-400 text-2xl"></i>
</div>
<p>暂无简历</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if gt .TotalPages 1 }}
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
<div class="text-sm text-slate-500">共 {{ .Total }} 条记录,第 {{ .Page }} / {{ .TotalPages }} 页</div>
<div class="flex items-center gap-2">
<a href="?page=1{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
<i class="fas fa-chevron-left"></i>
</a>
<a href="?page={{ if eq .Page 1 }}1{{ else }}{{ subtract .Page 1 }}{{ end }}{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
</a>
{{ if gt .TotalPages 7 }}
{{ if gt .Page 3 }}
<a href="?page=1{{ .FilterQuery }}" class="btn btn-sm btn-slate">1</a>
{{ if gt .Page 4 }}
<span class="text-slate-400 text-sm">...</span>
{{ end }}
{{ end }}
{{ end }}
{{ range $i := to 1 .TotalPages }}
{{ if and (ge $i (subtract $.Page 2)) (le $i (add $.Page 2)) }}
<a href="?page={{ $i }}{{ $.FilterQuery }}" class="btn btn-sm {{ if eq $i $.Page }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
{{ end }}
{{ end }}
{{ if gt .TotalPages 7 }}
{{ if lt .Page (subtract .TotalPages 2) }}
{{ if lt .Page (subtract .TotalPages 3) }}
<span class="text-slate-400 text-sm">...</span>
{{ end }}
<a href="?page={{ .TotalPages }}{{ .FilterQuery }}" class="btn btn-sm btn-slate">{{ .TotalPages }}</a>
{{ end }}
{{ end }}
<a href="?page={{ if eq .Page .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .Page 1 }}{{ end }}{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
</a>
<a href="?page={{ .TotalPages }}{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
<i class="fas fa-chevron-right"></i>
</a>
</div>
</div>
{{ end }}
</div>
<div class="modal" id="addResumeModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="addResumeModalTitle">
<i class="fas fa-file-plus text-blue-500"></i> 添加简历
</h3>
<div class="modal-close" onclick="closeAddResumeModal()">
<i class="fas fa-times"></i>
</div>
</div>
<div class="modal-body">
<form id="addResumeForm" onsubmit="saveResume(event)">
<input type="hidden" id="resumeId">
<div class="space-y-4">
<div>
<label class="form-label">姓名</label>
<input type="text" id="resumeName" class="form-input" required>
</div>
<div>
<label class="form-label">职位</label>
<input type="text" id="resumeTitle" class="form-input" required>
</div>
<div>
<label class="form-label">访问路由(如 zhangsan</label>
<input type="text" id="resumeRoute" class="form-input" required>
</div>
<div>
<label class="form-label">绑定人员(可选,可后续绑定)</label>
<select id="resumeUserId" class="form-input">
<option value="">不绑定人员</option>
{{ range .Users }}
<option value="{{.ID}}">{{.Name}} - {{.Username}}</option>
{{ end }}
</select>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button onclick="closeAddResumeModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button type="submit" form="addResumeForm" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</div>
</div>
<div class="modal" id="bindUserModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">
<i class="fas fa-link text-green-500"></i> 绑定人员
</h3>
<div class="modal-close" onclick="closeBindUserModal()">
<i class="fas fa-times"></i>
</div>
</div>
<div class="modal-body">
<div class="mb-4">
<label class="form-label">选择要绑定的人员</label>
<select id="bindUserId" class="form-input">
{{ range .Users }}
<option value="{{.ID}}">{{.Name}} - {{.Username}}</option>
{{ end }}
</select>
</div>
</div>
<div class="modal-footer">
<button onclick="closeBindUserModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button onclick="confirmBindUser()" class="btn btn-primary">
<i class="fas fa-check"></i> 确认绑定
</button>
</div>
</div>
</div>
<div class="modal" id="passwordModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">
<i class="fas fa-lock text-yellow-500"></i> 设置访问密码
</h3>
<div class="modal-close" onclick="closePasswordModal()">
<i class="fas fa-times"></i>
</div>
</div>
<div class="modal-body">
<div class="space-y-4">
<div>
<label class="form-label">访问密码(留空则取消密码保护)</label>
<input type="password" id="resumePassword" class="form-input" placeholder="请输入密码">
</div>
<div>
<label class="form-label">确认密码</label>
<input type="password" id="resumePasswordConfirm" class="form-input" placeholder="请再次输入密码">
</div>
</div>
</div>
<div class="modal-footer">
<button onclick="closePasswordModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button onclick="confirmUpdatePassword()" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</div>
</div>
<div class="modal" id="uploadDocumentModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">
<i class="fas fa-upload text-green-500"></i> 上传文档生成简历
</h3>
<div class="modal-close" onclick="closeUploadDocumentModal()">
<i class="fas fa-times"></i>
</div>
</div>
<div class="modal-body">
<form id="uploadDocumentForm" enctype="multipart/form-data" onsubmit="uploadDocument(event)">
<div class="space-y-4">
<div>
<label class="form-label">选择文档</label>
<div class="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:border-green-500 transition-colors" id="fileDropArea">
<i class="fas fa-file-upload text-4xl text-slate-400 mb-4"></i>
<p class="text-slate-600 mb-2">点击或拖拽文件到此处</p>
<p class="text-sm text-slate-400">支持格式:PDF、DOCX、XLSX、MD、TXT</p>
<input type="file" id="documentFile" name="file" accept=".pdf,.docx,.xlsx,.md,.txt" class="hidden" onchange="handleFileSelect(this)">
</div>
<div id="selectedFileInfo" class="mt-2 hidden">
<div class="flex items-center gap-2 text-sm text-slate-600">
<i class="fas fa-file"></i>
<span id="selectedFileName"></span>
<button type="button" onclick="clearFileSelection()" class="text-red-500 hover:text-red-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<div>
<label class="form-label">绑定人员(可选)</label>
<select id="documentUserId" class="form-input">
<option value="">不绑定人员</option>
{{ range .Users }}
<option value="{{.ID}}">{{.Name}} - {{.Username}}</option>
{{ end }}
</select>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button onclick="closeUploadDocumentModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button type="submit" form="uploadDocumentForm" class="btn btn-success" id="uploadSubmitBtn">
<i class="fas fa-upload"></i> 上传生成
</button>
</div>
</div>
</div>
<script>
var currentBindResumeId = '';
var currentPasswordResumeId = '';
function openUploadDocumentModal() {
document.getElementById('uploadDocumentModal').classList.add('active');
document.getElementById('documentFile').value = '';
document.getElementById('selectedFileInfo').classList.add('hidden');
document.getElementById('selectedFileName').textContent = '';
document.getElementById('uploadSubmitBtn').disabled = false;
document.getElementById('uploadSubmitBtn').innerHTML = '<i class="fas fa-upload"></i> 上传生成';
}
function closeUploadDocumentModal() {
document.getElementById('uploadDocumentModal').classList.remove('active');
}
document.getElementById('fileDropArea').addEventListener('click', function() {
document.getElementById('documentFile').click();
});
document.getElementById('fileDropArea').addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('border-green-500');
});
document.getElementById('fileDropArea').addEventListener('dragleave', function(e) {
e.preventDefault();
this.classList.remove('border-green-500');
});
document.getElementById('fileDropArea').addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('border-green-500');
var files = e.dataTransfer.files;
if (files.length > 0) {
document.getElementById('documentFile').files = files;
handleFileSelect(document.getElementById('documentFile'));
}
});
function handleFileSelect(input) {
if (input.files.length > 0) {
var file = input.files[0];
document.getElementById('selectedFileName').textContent = file.name;
document.getElementById('selectedFileInfo').classList.remove('hidden');
}
}
function clearFileSelection() {
document.getElementById('documentFile').value = '';
document.getElementById('selectedFileInfo').classList.add('hidden');
document.getElementById('selectedFileName').textContent = '';
}
function uploadDocument(event) {
event.preventDefault();
var fileInput = document.getElementById('documentFile');
var userId = document.getElementById('documentUserId').value;
if (fileInput.files.length === 0) {
showCustomAlert('error', '错误', '请选择要上传的文件');
return;
}
var file = fileInput.files[0];
var allowedExts = ['.pdf', '.docx', '.xlsx', '.md', '.txt'];
var fileExt = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
if (!allowedExts.includes(fileExt)) {
showCustomAlert('error', '错误', '不支持的文件格式,支持:PDF、DOCX、XLSX、MD、TXT');
return;
}
var formData = new FormData();
formData.append('file', file);
if (userId) {
formData.append('user_id', userId);
}
var submitBtn = document.getElementById('uploadSubmitBtn');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
fetch('/dashboard/api/resume/generate-from-document', {
method: 'POST',
body: formData,
credentials: 'include'
})
.then(response => {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
})
.then(result => {
console.log('API Response:', result);
if (result.success) {
showCustomAlert('success', '成功', result.message || '简历生成成功!');
closeUploadDocumentModal();
setTimeout(() => location.reload(), 2000);
} else {
showCustomAlert('error', '错误', result.message || '生成失败');
}
})
.catch(error => {
console.error('Upload error:', error);
showCustomAlert('error', '错误', '上传失败: ' + error.message);
})
.finally(() => {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-upload"></i> 上传生成';
});
}
function openAddResumeModal() {
document.getElementById('resumeId').value = '';
document.getElementById('resumeName').value = '';
document.getElementById('resumeTitle').value = '';
document.getElementById('resumeRoute').value = '';
document.getElementById('resumeUserId').value = '';
document.getElementById('addResumeModal').classList.add('active');
}
function closeAddResumeModal() {
document.getElementById('addResumeModal').classList.remove('active');
}
function saveResume(event) {
event.preventDefault();
const id = document.getElementById('resumeId').value;
const name = document.getElementById('resumeName').value;
const title = document.getElementById('resumeTitle').value;
const route = document.getElementById('resumeRoute').value;
const userId = document.getElementById('resumeUserId').value;
if (!name || !title || !route) {
showCustomAlert('error', '错误', '请填写完整信息');
return;
}
fetch('/dashboard/api/resume/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
route: route,
basic_info: {
name: name,
title: title
}
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '添加成功');
closeAddResumeModal();
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '添加失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '添加失败');
});
}
function bindUser(resumeId) {
currentBindResumeId = resumeId;
document.getElementById('bindUserModal').classList.add('active');
}
function closeBindUserModal() {
document.getElementById('bindUserModal').classList.remove('active');
currentBindResumeId = '';
}
function confirmBindUser() {
const userId = document.getElementById('bindUserId').value;
if (!userId) {
showCustomAlert('error', '错误', '请选择要绑定的人员');
return;
}
fetch('/dashboard/api/resume/' + currentBindResumeId + '/user', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '绑定成功');
closeBindUserModal();
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '绑定失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '绑定失败');
});
}
function unbindUser(resumeId) {
showCustomConfirm('确认解绑', '确定要解绑该简历与人员的关联吗?', function(confirmed) {
if (!confirmed) return;
fetch('/dashboard/api/resume/' + resumeId + '/user', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: '' })
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '解绑成功');
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '解绑失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '解绑失败');
});
});
}
function toggleShowInHome(resumeId, currentState) {
fetch('/dashboard/api/resume/' + resumeId + '/show_in_home', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ show_in_home: !currentState })
})
.then(response => response.json())
.then(result => {
if (result.success !== undefined) {
showCustomAlert('success', '成功', currentState ? '已取消首页展示' : '已设置首页展示');
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '操作失败');
}
})
.catch(() => {
showCustomAlert('error', '错误', '操作失败');
});
}
function updatePassword(resumeId) {
currentPasswordResumeId = resumeId;
document.getElementById('resumePassword').value = '';
document.getElementById('resumePasswordConfirm').value = '';
document.getElementById('passwordModal').classList.add('active');
}
function closePasswordModal() {
document.getElementById('passwordModal').classList.remove('active');
currentPasswordResumeId = '';
}
function confirmUpdatePassword() {
const password = document.getElementById('resumePassword').value;
const confirmPassword = document.getElementById('resumePasswordConfirm').value;
if (password !== confirmPassword) {
showCustomAlert('error', '错误', '两次输入的密码不一致');
return;
}
fetch('/dashboard/api/resume/' + currentPasswordResumeId + '/password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: password })
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', password ? '密码设置成功' : '密码已取消');
closePasswordModal();
} else {
showCustomAlert('error', '错误', '设置失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '设置失败');
});
}
function deleteResume(resumeId) {
showCustomConfirm('确认删除', '确定要删除该简历吗?此操作不可恢复。', function(confirmed) {
if (!confirmed) return;
fetch('/dashboard/api/resume/' + resumeId, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '删除成功');
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '删除失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '删除失败');
});
});
}
</script>
+347
View File
@@ -0,0 +1,347 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-cog text-purple-500"></i> 系统设置
</h3>
<p class="text-sm text-slate-500 mt-1">配置系统参数</p>
</div>
<button onclick="saveSettings()" class="btn btn-primary">
<i class="fas fa-save"></i> 保存设置
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="p-6 bg-slate-50 rounded-lg">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-globe text-blue-500"></i> 网站基本设置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">网站域名</label>
<input type="text" id="settingDomain" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.WebsiteDomain}}">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">网站标题</label>
<input type="text" id="settingTitle" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.WebsiteTitle}}">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">网站描述</label>
<textarea id="settingDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.Config.WebsiteDescription}}</textarea>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">网站关键词</label>
<input type="text" id="settingKeywords" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.WebsiteKeywords}}" placeholder="多个关键词用逗号分隔">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">管理员邮箱</label>
<input type="email" id="settingAdminEmail" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.AdminEmail}}">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">管理员姓名</label>
<input type="text" id="settingAdminName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.AdminName}}">
</div>
</div>
</div>
<div class="p-6 bg-slate-50 rounded-lg">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-image text-blue-500"></i> Logo设置
</h4>
<div class="flex items-center gap-6">
<div class="flex-shrink-0">
{{ if .Config.LogoPath }}
<div class="relative group">
<img id="logoPreview" src="{{.Config.LogoPath}}" data-path="{{.Config.LogoPath}}" class="w-24 h-24 rounded-xl object-cover border-2 border-blue-200 shadow-md">
<button onclick="clearLogo()" class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center shadow-lg hover:bg-red-600">
<i class="fas fa-times text-xs"></i>
</button>
</div>
{{ else }}
<div id="logoPreview" class="w-24 h-24 rounded-xl bg-white border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:text-blue-500 transition-all cursor-pointer" onclick="document.getElementById('logoFile').click()">
<i class="fas fa-image text-2xl mb-1"></i>
<span class="text-xs">点击上传</span>
</div>
{{ end }}
</div>
<div class="flex-1">
<div id="logoDropZone" class="w-full border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-blue-400 hover:bg-blue-50 transition-all cursor-pointer" onclick="document.getElementById('logoFile').click()">
<i class="fas fa-cloud-upload-alt text-blue-500 text-3xl mb-3"></i>
<p class="text-sm text-slate-600">点击或拖动文件到此处自动上传</p>
</div>
<input type="file" id="logoFile" accept="image/*" class="hidden" onchange="handleLogoSelect(this)">
</div>
</div>
</div>
<div class="p-6 bg-slate-50 rounded-lg">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-list text-blue-500"></i> 后台设置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">后台列表每页数据条数</label>
<input type="number" id="settingAdminPageSize" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.AdminPageSize}}" min="1" max="100">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">SEO 关键词</label>
<input type="text" id="settingSEOKeywords" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.SEOKeywords}}" placeholder="多个关键词用逗号分隔">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">SEO 描述</label>
<textarea id="settingSEODescription" rows="2" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.Config.SEODescription}}</textarea>
</div>
</div>
</div>
</div>
<div class="space-y-6">
<div class="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border border-blue-200">
<h4 class="font-medium text-blue-800 mb-4 flex items-center gap-2">
<i class="fas fa-server text-blue-600"></i> 系统信息
</h4>
<div class="space-y-3">
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">系统版本</span>
<span class="text-slate-800 font-medium">1.0.0</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">Go 版本</span>
<span class="text-slate-800 font-medium" id="sysGoVersion">-</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">数据库</span>
<span class="text-slate-800 font-medium">SQLite</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">操作系统</span>
<span class="text-slate-800 font-medium" id="sysOS">-</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">架构</span>
<span class="text-slate-800 font-medium" id="sysArch">-</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">启动时间</span>
<span class="text-slate-800 font-medium" id="sysStartTime">-</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">运行时长</span>
<span class="text-slate-800 font-medium" id="sysUptime">-</span>
</div>
</div>
</div>
<div class="p-6 bg-slate-50 rounded-lg">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-history text-blue-500"></i> 最近登录记录
</h4>
<div id="loginHistoryList" class="space-y-3 max-h-60 overflow-y-auto">
<div class="text-center text-slate-400 text-sm py-4">加载中...</div>
</div>
</div>
</div>
</div>
</div>
<script>
function handleLogoSelect(input) {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
const preview = document.getElementById('logoPreview');
if (preview.tagName === 'IMG') {
preview.src = e.target.result;
// 上传未完成前先清空 data-path,等待 uploadLogo 完成后再回填服务端路径
preview.removeAttribute('data-path');
} else {
preview.outerHTML = '<div id="logoPreview" class="relative group"><img src="' + e.target.result + '" class="w-24 h-24 rounded-xl object-cover border-2 border-blue-200 shadow-md"><button onclick="clearLogo()" class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center shadow-lg hover:bg-red-600"><i class="fas fa-times text-xs"></i></button></div>';
}
}
reader.readAsDataURL(input.files[0]);
uploadLogo(input.files[0]);
}
}
function clearLogo() {
const preview = document.getElementById('logoPreview');
preview.outerHTML = '<div id="logoPreview" class="w-24 h-24 rounded-xl bg-white border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:text-blue-500 transition-all cursor-pointer" onclick="document.getElementById(\'logoFile\').click()"><i class="fas fa-image text-2xl mb-1"></i><span class="text-xs">点击上传</span></div>';
showCustomAlert('success', '成功', '已清除预览,请点击保存设置生效');
}
function setupDropZone() {
const dropZone = document.getElementById('logoDropZone');
const fileInput = document.getElementById('logoFile');
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
dropZone.classList.add('border-blue-500', 'bg-blue-50');
});
dropZone.addEventListener('dragleave', function(e) {
e.preventDefault();
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].type.startsWith('image/')) {
fileInput.files = files;
handleLogoSelect(fileInput);
} else {
showCustomAlert('error', '错误', '请上传图片文件');
}
});
}
function uploadLogo(file) {
const formData = new FormData();
formData.append('logo', file);
fetch('/dashboard/api/settings/logo', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(result => {
const logoPath = result.data && result.data.logo_path;
if (result.success && logoPath) {
const preview = document.getElementById('logoPreview');
if (preview.tagName === 'IMG') {
preview.src = logoPath;
preview.setAttribute('data-path', logoPath);
} else {
const img = preview.querySelector('img');
if (img) {
img.src = logoPath;
img.setAttribute('data-path', logoPath);
}
}
showCustomAlert('success', '成功', 'Logo上传成功');
} else {
showCustomAlert('error', '错误', '上传失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '上传失败');
});
}
function saveSettings() {
const logoPreview = document.getElementById('logoPreview');
let logoPath = '';
// 优先读取自定义 data-path 属性(保存的是相对路径),避免浏览器把 src 转为绝对 URL
if (logoPreview.tagName === 'IMG') {
logoPath = logoPreview.getAttribute('data-path') || '';
} else {
const img = logoPreview.querySelector('img');
if (img) logoPath = img.getAttribute('data-path') || '';
}
const adminPageSize = parseInt(document.getElementById('settingAdminPageSize').value, 10);
if (isNaN(adminPageSize) || adminPageSize < 1 || adminPageSize > 100) {
showCustomAlert('error', '错误', '后台列表每页数据条数必须在 1-100 之间');
return;
}
const data = {
website_domain: document.getElementById('settingDomain').value,
website_title: document.getElementById('settingTitle').value,
website_description: document.getElementById('settingDescription').value,
website_keywords: document.getElementById('settingKeywords').value,
admin_email: document.getElementById('settingAdminEmail').value,
admin_name: document.getElementById('settingAdminName').value,
logo_path: logoPath,
seo_keywords: document.getElementById('settingSEOKeywords').value,
seo_description: document.getElementById('settingSEODescription').value,
admin_page_size: adminPageSize || 20
};
fetch('/dashboard/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '设置保存成功');
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '保存失败');
});
}
function loadSystemInfo() {
fetch('/dashboard/api/settings/system-info')
.then(response => response.json())
.then(result => {
if (result.success) {
const data = result.data;
document.getElementById('sysGoVersion').textContent = data.go_version || '-';
document.getElementById('sysOS').textContent = data.os || '-';
document.getElementById('sysArch').textContent = data.arch || '-';
document.getElementById('sysStartTime').textContent = data.start_time || '-';
document.getElementById('sysUptime').textContent = data.uptime || '-';
}
})
.catch(error => {
console.error('Error loading system info:', error);
});
}
function loadLoginHistory() {
fetch('/dashboard/api/settings/login-history?page=1&pageSize=5')
.then(response => response.json())
.then(result => {
if (result.success) {
const historyList = document.getElementById('loginHistoryList');
if (result.data.list.length === 0) {
historyList.innerHTML = '<div class="text-center text-slate-400 text-sm py-4">暂无登录记录</div>';
return;
}
historyList.innerHTML = '';
result.data.list.forEach(item => {
const statusIcon = item.success
? '<i class="fas fa-check-circle text-green-500"></i>'
: '<i class="fas fa-times-circle text-red-500"></i>';
const statusText = item.success ? '成功' : '失败';
const itemDiv = document.createElement('div');
itemDiv.className = 'flex items-center justify-between p-3 bg-white rounded-lg border border-slate-100';
itemDiv.innerHTML = `
<div class="flex items-center gap-2">
${statusIcon}
<span class="text-sm text-slate-700">${item.user_name || '未知用户'}</span>
</div>
<div class="text-right">
<div class="text-xs text-slate-500">${item.ip}</div>
<div class="text-xs text-slate-400">${item.created_at}</div>
</div>
`;
historyList.appendChild(itemDiv);
});
}
})
.catch(error => {
console.error('Error loading login history:', error);
document.getElementById('loginHistoryList').innerHTML = '<div class="text-center text-slate-400 text-sm py-4">加载失败</div>';
});
}
setupDropZone();
loadSystemInfo();
loadLoginHistory();
</script>
+278
View File
@@ -0,0 +1,278 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-folder-open text-purple-500"></i> {{ if .User }}{{.User.Name}}{{ else }}未知用户{{ end }} 的作品集管理
</h3>
<p class="text-sm text-slate-500 mt-1">管理用户的作品集项目</p>
</div>
<div class="flex items-center gap-3">
<a href="{{.AdminPath}}/portfolio/add" class="btn btn-primary">
<i class="fas fa-plus"></i> 添加项目
</a>
<a href="{{.AdminPath}}/users" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回列表
</a>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{{ range .PortfolioItems }}
<div class="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-primary-300 transition-all {{ if .Hidden }}opacity-50{{ end }}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<h4 class="font-medium text-slate-800">{{.Name}}</h4>
{{ if .Hidden }}
<span class="px-2 py-0.5 bg-gray-200 text-gray-600 rounded text-xs">已隐藏</span>
{{ end }}
{{ if .Password }}
<span class="px-2 py-0.5 bg-amber-100 text-amber-700 rounded text-xs"><i class="fas fa-lock"></i></span>
{{ end }}
</div>
<div class="btn-group">
<a href="{{$.AdminPath}}/portfolio/{{.ID}}/edit" class="btn btn-warning">
<i class="fas fa-edit"></i> 编辑
</a>
<button onclick="togglePortfolioHidden('{{.ID}}', {{.Hidden}})" class="btn btn-{{ if .Hidden }}success{{ else }}secondary{{ end }}">
<i class="fas {{ if .Hidden }}fa-eye{{ else }}fa-eye-slash{{ end }}"></i> {{ if .Hidden }}显示{{ else }}隐藏{{ end }}
</button>
<button onclick="deletePortfolio('{{.ID}}')" class="btn btn-danger">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</div>
<p class="text-sm text-slate-600 mb-3">{{.Description}}</p>
<div class="flex flex-wrap gap-1 mb-3">
{{ range .TechStack }}
<span class="px-2 py-0.5 bg-slate-200 text-slate-600 rounded text-xs">{{.}}</span>
{{ end }}
</div>
<div class="flex items-center justify-between text-xs text-slate-500">
<span>{{.StartDate}} ~ {{.EndDate}}</span>
{{ if .URL }}
<a href="{{.URL}}" target="_blank" class="text-primary-600 hover:text-primary-700">
<i class="fas fa-external-link-alt"></i> 查看
</a>
{{ end }}
</div>
</div>
{{ else }}
<div class="col-span-full text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-folder-open text-slate-400 text-2xl"></i>
</div>
<h4 class="text-lg font-semibold text-slate-700 mb-2">暂无作品集项目</h4>
<p class="text-slate-500">该用户还没有添加作品集项目</p>
</div>
{{ end }}
</div>
</div>
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="portfolioModal">
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
<div class="flex items-center justify-between p-4 border-b border-slate-200">
<h3 class="font-semibold text-slate-800" id="portfolioModalTitle">添加项目</h3>
<button onclick="closePortfolioModal()" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6">
<form id="portfolioForm" onsubmit="savePortfolio(event)">
<input type="hidden" id="portfolioId">
<input type="hidden" id="portfolioUserId" value="{{ with .User }}{{.ID}}{{ end }}">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目名称</label>
<input type="text" id="portfolioName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目描述</label>
<textarea id="portfolioDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">技术栈 (每行一个)</label>
<textarea id="portfolioTechStack" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="Go&#10;React&#10;MySQL"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目链接</label>
<input type="url" id="portfolioURL" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
<input type="text" id="portfolioStartDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="2024-01">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
<input type="text" id="portfolioEndDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="2024-12">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">关联简历项目</label>
<select id="portfolioProjectID" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="0">不关联简历项目</option>
</select>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="portfolioShowInResume" class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500">
<span class="text-sm font-medium text-slate-700">在简历中显示</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="portfolioHidden" class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500">
<span class="text-sm font-medium text-slate-700">隐藏项目</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">访问密码(留空则不设密码)</label>
<input type="password" id="portfolioPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="输入访问密码">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目文档 (Markdown格式)</label>
<textarea id="portfolioDoc" rows="8" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm" placeholder="# 项目概述&#10;&#10;这里编写项目详细说明..."></textarea>
</div>
</div>
<div class="flex items-center justify-center gap-3 mt-6">
<button type="button" onclick="closePortfolioModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openAddPortfolioModal() {
document.getElementById('portfolioModalTitle').textContent = '添加项目';
document.getElementById('portfolioId').value = '';
document.getElementById('portfolioName').value = '';
document.getElementById('portfolioDescription').value = '';
document.getElementById('portfolioTechStack').value = '';
document.getElementById('portfolioURL').value = '';
document.getElementById('portfolioStartDate').value = '';
document.getElementById('portfolioEndDate').value = '';
document.getElementById('portfolioProjectID').value = '0';
document.getElementById('portfolioShowInResume').checked = false;
document.getElementById('portfolioHidden').checked = false;
document.getElementById('portfolioPassword').value = '';
document.getElementById('portfolioDoc').value = '';
document.getElementById('portfolioModal').classList.remove('hidden');
}
function closePortfolioModal() {
document.getElementById('portfolioModal').classList.add('hidden');
}
function editPortfolio(id) {
fetch('/dashboard/api/portfolio/' + id)
.then(response => response.json())
.then(result => {
if (!result.success || !result.data) {
showCustomAlert('error', '错误', '获取项目信息失败');
return;
}
const item = result.data;
document.getElementById('portfolioModalTitle').textContent = '编辑项目';
document.getElementById('portfolioId').value = item.id;
document.getElementById('portfolioName').value = item.name;
document.getElementById('portfolioDescription').value = item.description || '';
document.getElementById('portfolioTechStack').value = (item.tech_stack || []).join('\n');
document.getElementById('portfolioURL').value = item.url || '';
document.getElementById('portfolioStartDate').value = item.start_date || '';
document.getElementById('portfolioEndDate').value = item.end_date || '';
document.getElementById('portfolioProjectID').value = item.project_id || '0';
document.getElementById('portfolioShowInResume').checked = item.show_in_resume || false;
document.getElementById('portfolioHidden').checked = item.hidden || false;
document.getElementById('portfolioPassword').value = item.password || '';
document.getElementById('portfolioDoc').value = item.doc || '';
document.getElementById('portfolioModal').classList.remove('hidden');
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '获取项目信息失败');
});
}
function deletePortfolio(id) {
showCustomConfirm('确认删除', '确定要删除该项目吗?', function(confirmed) {
if (!confirmed) return;
fetch('/dashboard/api/portfolio/' + id, { method: 'DELETE' })
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
showCustomAlert('error', '错误', '删除失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '删除失败');
});
});
}
function savePortfolio(event) {
event.preventDefault();
const id = document.getElementById('portfolioId').value;
const userId = document.getElementById('portfolioUserId').value;
const data = {
user_id: userId,
name: document.getElementById('portfolioName').value,
description: document.getElementById('portfolioDescription').value,
tech_stack: document.getElementById('portfolioTechStack').value.split('\n').map(s => s.trim()).filter(s => s),
url: document.getElementById('portfolioURL').value,
start_date: document.getElementById('portfolioStartDate').value,
end_date: document.getElementById('portfolioEndDate').value,
project_id: parseInt(document.getElementById('portfolioProjectID').value) || 0,
show_in_resume: document.getElementById('portfolioShowInResume').checked,
hidden: document.getElementById('portfolioHidden').checked,
password: document.getElementById('portfolioPassword').value,
doc: document.getElementById('portfolioDoc').value
};
const url = id ? '/dashboard/api/portfolio/' + id : '/dashboard/api/portfolio';
const method = id ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '保存成功');
closePortfolioModal();
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '保存失败');
});
}
function togglePortfolioHidden(id, isHidden) {
fetch('/dashboard/api/portfolio/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !isHidden })
})
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
showCustomAlert('error', '错误', '操作失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '操作失败');
});
}
</script>

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