Initial commit
This commit is contained in:
commit
a8a5f6e866
13
.env.example
Normal file
13
.env.example
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
PORT=3001
|
||||||
|
MONGO_URL=mongodb://localhost:27017
|
||||||
|
DB_NAME=skills_market
|
||||||
|
|
||||||
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
ALIYUN_ACCESS_KEY_ID=LTAI5tP7ufyq46H86SrzmxPL
|
||||||
|
ALIYUN_ACCESS_KEY_SECRET=PFqfWD4POJnzYjqGv7S0YygemaC8GS
|
||||||
|
ALIYUN_DM_ACCOUNT_NAME=login@mail.como.video
|
||||||
|
ALIYUN_DM_TEMPLATE_ID=418198
|
||||||
|
ALIYUN_DM_DEFAULT_SUBJECT=登录验证码
|
||||||
|
ALIYUN_DM_ENDPOINT=dm.aliyuncs.com
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
88
README.md
Normal file
88
README.md
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Skills Market Server
|
||||||
|
|
||||||
|
Skills Market API 服务,使用 Express + MongoDB。
|
||||||
|
|
||||||
|
## 安装 MongoDB
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
1. 下载 MongoDB Community Edition: https://www.mongodb.com/try/download/community
|
||||||
|
2. 安装后,MongoDB 会作为 Windows 服务自动启动
|
||||||
|
3. 默认端口: 27017
|
||||||
|
|
||||||
|
### 或使用 Docker
|
||||||
|
```bash
|
||||||
|
docker run -d -p 27017:27017 --name mongodb mongo:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 开发模式(自动重启)
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/skills` | GET | 获取 skills 列表 |
|
||||||
|
| `/api/skills/:name` | GET | 获取 skill 详情 |
|
||||||
|
| `/api/skills/:name/download` | GET | 下载 skill(增加下载计数) |
|
||||||
|
| `/api/skills/:name/publish` | POST | 发布/更新 skill |
|
||||||
|
| `/api/skills/:name/versions` | GET | 获取版本历史 |
|
||||||
|
| `/api/skills/:name/versions/:version` | GET | 获取特定版本 |
|
||||||
|
| `/api/skills/:name` | DELETE | 删除 skill |
|
||||||
|
| `/api/health` | GET | 健康检查 |
|
||||||
|
| `/api/stats` | GET | 统计信息 |
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
创建 `.env` 文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
PORT=3001
|
||||||
|
MONGO_URL=mongodb://localhost:27017
|
||||||
|
DB_NAME=skills_market
|
||||||
|
```
|
||||||
|
|
||||||
|
## MongoDB 数据结构
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// skills 集合
|
||||||
|
{
|
||||||
|
"_id": ObjectId("..."),
|
||||||
|
"name": "agent-browser",
|
||||||
|
"description": "Browser automation...",
|
||||||
|
"owner": "user123",
|
||||||
|
"downloads": 150,
|
||||||
|
"is_public": true,
|
||||||
|
"tags": ["browser", "automation"],
|
||||||
|
|
||||||
|
// 文件数组
|
||||||
|
"files": [
|
||||||
|
{ "path": "SKILL.md", "content": "---\nname: ..." },
|
||||||
|
{ "path": "references/commands.md", "content": "# Commands..." },
|
||||||
|
{ "path": "templates/auth.sh", "content": "#!/bin/bash..." }
|
||||||
|
],
|
||||||
|
|
||||||
|
// 版本历史
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"description": "Initial version",
|
||||||
|
"files": [ /* 快照 */ ],
|
||||||
|
"created_at": ISODate("..."),
|
||||||
|
"created_by": "user123"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"created_at": ISODate("..."),
|
||||||
|
"updated_at": ISODate("...")
|
||||||
|
}
|
||||||
|
```
|
||||||
1621
package-lock.json
generated
Normal file
1621
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "skills-market-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Skills Market API Server with MongoDB",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/dm20151123": "^1.8.3",
|
||||||
|
"@alicloud/openapi-client": "^0.4.15",
|
||||||
|
"@alicloud/tea-util": "^1.4.11",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mongodb": "^6.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
167
routes/auth.js
Normal file
167
routes/auth.js
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
const jwt = require('jsonwebtoken')
|
||||||
|
const { sendVerificationCode, verifyCode } = require('../services/auth')
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||||
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'
|
||||||
|
|
||||||
|
function createAuthRoutes(db) {
|
||||||
|
const usersCollection = db.collection('users')
|
||||||
|
|
||||||
|
return {
|
||||||
|
async sendCode(req, res) {
|
||||||
|
try {
|
||||||
|
const { email } = req.body
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return res.status(400).json({ success: false, error: '邮箱不能为空' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendVerificationCode(db, email.toLowerCase())
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: '验证码已发送' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Send code error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(req, res) {
|
||||||
|
try {
|
||||||
|
const { email, code, nickname } = req.body
|
||||||
|
|
||||||
|
if (!email || !code) {
|
||||||
|
return res.status(400).json({ success: false, error: '邮箱和验证码不能为空' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailLower = email.toLowerCase()
|
||||||
|
const verifyResult = await verifyCode(db, emailLower, code)
|
||||||
|
|
||||||
|
if (!verifyResult.success) {
|
||||||
|
return res.status(400).json(verifyResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await usersCollection.findOne({ email: emailLower })
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const newUser = {
|
||||||
|
email: emailLower,
|
||||||
|
nickname: nickname || emailLower.split('@')[0],
|
||||||
|
avatar: null,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
last_login: new Date(),
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
const result = await usersCollection.insertOne(newUser)
|
||||||
|
user = { ...newUser, _id: result.insertedId }
|
||||||
|
} else {
|
||||||
|
await usersCollection.updateOne(
|
||||||
|
{ _id: user._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
last_login: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
userId: user._id.toString(),
|
||||||
|
email: user.email
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: JWT_EXPIRES_IN }
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user._id,
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatar: user.avatar,
|
||||||
|
created_at: user.created_at
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Login error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async verifyToken(req, res, next) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ success: false, error: '未登录' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET)
|
||||||
|
const user = await usersCollection.findOne({ _id: new (require('mongodb').ObjectId)(decoded.userId) })
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ success: false, error: '用户不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id: user._id.toString(),
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatar: user.avatar
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
} catch (jwtErr) {
|
||||||
|
return res.status(401).json({ success: false, error: 'Token无效或已过期' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Verify token error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProfile(req, res) {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, user: req.user })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Get profile error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateProfile(req, res) {
|
||||||
|
try {
|
||||||
|
const { nickname, avatar } = req.body
|
||||||
|
const updateData = { updated_at: new Date() }
|
||||||
|
|
||||||
|
if (nickname) updateData.nickname = nickname
|
||||||
|
if (avatar) updateData.avatar = avatar
|
||||||
|
|
||||||
|
await usersCollection.updateOne(
|
||||||
|
{ _id: new (require('mongodb').ObjectId)(req.user.id) },
|
||||||
|
{ $set: updateData }
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
user: { ...req.user, ...updateData }
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Update profile error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createAuthRoutes }
|
||||||
475
server.js
Normal file
475
server.js
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
require('dotenv').config()
|
||||||
|
const express = require('express')
|
||||||
|
const cors = require('cors')
|
||||||
|
const { MongoClient, ObjectId } = require('mongodb')
|
||||||
|
const { createAuthRoutes } = require('./routes/auth')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const PORT = process.env.PORT || 3001
|
||||||
|
const MONGO_URL = process.env.MONGO_URL || 'mongodb://localhost:27017'
|
||||||
|
const DB_NAME = process.env.DB_NAME || 'skills_market'
|
||||||
|
|
||||||
|
app.use(cors())
|
||||||
|
app.use(express.json({ limit: '50mb' }))
|
||||||
|
|
||||||
|
let db
|
||||||
|
let skillsCollection
|
||||||
|
let authRoutes
|
||||||
|
|
||||||
|
async function connectDB() {
|
||||||
|
const client = new MongoClient(MONGO_URL)
|
||||||
|
await client.connect()
|
||||||
|
db = client.db(DB_NAME)
|
||||||
|
skillsCollection = db.collection('skills')
|
||||||
|
authRoutes = createAuthRoutes(db)
|
||||||
|
console.log(`[MongoDB] Connected to ${DB_NAME}`)
|
||||||
|
|
||||||
|
await skillsCollection.createIndex({ name: 1 }, { unique: true })
|
||||||
|
await skillsCollection.createIndex({ owner: 1 })
|
||||||
|
await skillsCollection.createIndex({ is_public: 1 })
|
||||||
|
|
||||||
|
const usersCollection = db.collection('users')
|
||||||
|
await usersCollection.createIndex({ email: 1 }, { unique: true })
|
||||||
|
|
||||||
|
const codesCollection = db.collection('verification_codes')
|
||||||
|
await codesCollection.createIndex({ email: 1 })
|
||||||
|
await codesCollection.createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDescription(files) {
|
||||||
|
const skillFile = files.find(f => f.path === 'SKILL.md' || f.path.endsWith('SKILL.md'))
|
||||||
|
if (!skillFile) return ''
|
||||||
|
|
||||||
|
const content = skillFile.content
|
||||||
|
const fmMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/)
|
||||||
|
if (fmMatch) {
|
||||||
|
const descMatch = fmMatch[1].match(/^description:\s*(.+)$/m)
|
||||||
|
if (descMatch) {
|
||||||
|
return descMatch[1].trim().replace(/^["']|["']$/g, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split('\n')
|
||||||
|
let inFrontmatter = false
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed === '---') {
|
||||||
|
inFrontmatter = !inFrontmatter
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inFrontmatter) continue
|
||||||
|
if (!trimmed) continue
|
||||||
|
if (trimmed.startsWith('#')) continue
|
||||||
|
return trimmed.length > 120 ? trimmed.slice(0, 120) + '...' : trimmed
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/auth/send-code', async (req, res) => {
|
||||||
|
await authRoutes.sendCode(req, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
await authRoutes.login(req, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/auth/profile', async (req, res, next) => {
|
||||||
|
authRoutes.verifyToken(req, res, async () => {
|
||||||
|
await authRoutes.getProfile(req, res)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/auth/profile', async (req, res, next) => {
|
||||||
|
authRoutes.verifyToken(req, res, async () => {
|
||||||
|
await authRoutes.updateProfile(req, res)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/skills', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { query, offset = 0, limit = 50 } = req.query
|
||||||
|
|
||||||
|
let filter = { is_public: true }
|
||||||
|
if (query && query.trim()) {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
filter.$or = [
|
||||||
|
{ name: { $regex: q, $options: 'i' } },
|
||||||
|
{ description: { $regex: q, $options: 'i' } },
|
||||||
|
{ owner: { $regex: q, $options: 'i' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await skillsCollection.countDocuments(filter)
|
||||||
|
const skills = await skillsCollection
|
||||||
|
.find(filter, {
|
||||||
|
projection: {
|
||||||
|
name: 1,
|
||||||
|
description: 1,
|
||||||
|
owner: 1,
|
||||||
|
downloads: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
tags: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort({ updated_at: -1 })
|
||||||
|
.skip(parseInt(offset))
|
||||||
|
.limit(parseInt(limit))
|
||||||
|
.toArray()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
total,
|
||||||
|
skills: skills.map(s => ({
|
||||||
|
id: s._id,
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
owner: s.owner,
|
||||||
|
downloads: s.downloads || 0,
|
||||||
|
updated_at: s.updated_at,
|
||||||
|
tags: s.tags || []
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] List skills error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/skills/:name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const skill = await skillsCollection.findOne({
|
||||||
|
name: req.params.name,
|
||||||
|
is_public: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
skill: {
|
||||||
|
id: skill._id,
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
owner: skill.owner,
|
||||||
|
downloads: skill.downloads || 0,
|
||||||
|
files: skill.files,
|
||||||
|
versions: skill.versions || [],
|
||||||
|
tags: skill.tags || [],
|
||||||
|
created_at: skill.created_at,
|
||||||
|
updated_at: skill.updated_at
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Get skill error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/skills/:name/download', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const skill = await skillsCollection.findOne({
|
||||||
|
name: req.params.name,
|
||||||
|
is_public: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await skillsCollection.updateOne(
|
||||||
|
{ _id: skill._id },
|
||||||
|
{ $inc: { downloads: 1 } }
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
files: skill.files,
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Download skill error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/skills/:name/publish', async (req, res) => {
|
||||||
|
authRoutes.verifyToken(req, res, async () => {
|
||||||
|
try {
|
||||||
|
const { files, description, tags, localModifiedAt } = req.body
|
||||||
|
const userName = req.user.nickname || req.user.email
|
||||||
|
|
||||||
|
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, error: 'No files provided' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillName = req.params.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')
|
||||||
|
const skillDescription = description || extractDescription(files)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const existingSkill = await skillsCollection.findOne({ name: skillName })
|
||||||
|
|
||||||
|
if (existingSkill) {
|
||||||
|
const remoteModifiedTime = new Date(existingSkill.updated_at).getTime()
|
||||||
|
const localModifiedTime = localModifiedAt ? new Date(localModifiedAt).getTime() : 0
|
||||||
|
|
||||||
|
if (localModifiedTime < remoteModifiedTime) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
conflict: true,
|
||||||
|
conflictInfo: {
|
||||||
|
remote_updated_at: existingSkill.updated_at,
|
||||||
|
local_modified_at: localModifiedAt,
|
||||||
|
remote_updated_by: existingSkill.updated_by || existingSkill.owner,
|
||||||
|
message: '远程有新版本,发布会丢失远程的修改'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionEntry = {
|
||||||
|
version: (existingSkill.versions?.length || 0) + 1,
|
||||||
|
description: `Updated by ${userName}`,
|
||||||
|
files: existingSkill.files,
|
||||||
|
created_at: now,
|
||||||
|
created_by: userName
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
$set: {
|
||||||
|
files,
|
||||||
|
description: skillDescription,
|
||||||
|
updated_at: now,
|
||||||
|
updated_by: userName,
|
||||||
|
tags: tags || existingSkill.tags || []
|
||||||
|
},
|
||||||
|
$push: { versions: versionEntry }
|
||||||
|
}
|
||||||
|
|
||||||
|
await skillsCollection.updateOne(
|
||||||
|
{ _id: existingSkill._id },
|
||||||
|
updateData
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
action: 'updated',
|
||||||
|
name: skillName,
|
||||||
|
version: versionEntry.version
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const newSkill = {
|
||||||
|
name: skillName,
|
||||||
|
description: skillDescription,
|
||||||
|
owner: userName,
|
||||||
|
files,
|
||||||
|
downloads: 0,
|
||||||
|
is_public: true,
|
||||||
|
tags: tags || [],
|
||||||
|
versions: [{
|
||||||
|
version: 1,
|
||||||
|
description: 'Initial version',
|
||||||
|
files,
|
||||||
|
created_at: now,
|
||||||
|
created_by: userName
|
||||||
|
}],
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
created_by: userName,
|
||||||
|
updated_by: userName
|
||||||
|
}
|
||||||
|
|
||||||
|
await skillsCollection.insertOne(newSkill)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
action: 'created',
|
||||||
|
name: skillName,
|
||||||
|
version: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Publish skill error:', err)
|
||||||
|
if (err.code === 11000) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Skill name already exists'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/skills/:name/versions', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const skill = await skillsCollection.findOne(
|
||||||
|
{ name: req.params.name },
|
||||||
|
{ projection: { versions: 1 } }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = (skill.versions || []).map(v => ({
|
||||||
|
version: v.version,
|
||||||
|
description: v.description,
|
||||||
|
created_at: v.created_at,
|
||||||
|
created_by: v.created_by
|
||||||
|
}))
|
||||||
|
|
||||||
|
res.json({ success: true, versions })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Get versions error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/skills/:name/versions/:version', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const versionNum = parseInt(req.params.version)
|
||||||
|
|
||||||
|
const skill = await skillsCollection.findOne(
|
||||||
|
{ name: req.params.name },
|
||||||
|
{ projection: { versions: 1 } }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = (skill.versions || []).find(v => v.version === versionNum)
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Version not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
version: {
|
||||||
|
version: version.version,
|
||||||
|
description: version.description,
|
||||||
|
files: version.files,
|
||||||
|
created_at: version.created_at,
|
||||||
|
created_by: version.created_by
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Get version error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/skills/:name', async (req, res) => {
|
||||||
|
authRoutes.verifyToken(req, res, async () => {
|
||||||
|
try {
|
||||||
|
const skill = await skillsCollection.findOne({ name: req.params.name })
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await skillsCollection.deleteOne({ _id: skill._id })
|
||||||
|
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Delete skill error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Temporary endpoint to update skill version for testing
|
||||||
|
app.get('/api/test/update-skill/:name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Missing skill name' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const skill = await skillsCollection.findOne({ name })
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const versionEntry = {
|
||||||
|
version: (skill.versions?.length || 0) + 1,
|
||||||
|
description: `Updated for testing - ${now.toISOString()}`,
|
||||||
|
files: skill.files,
|
||||||
|
created_at: now,
|
||||||
|
created_by: 'System'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
$set: {
|
||||||
|
updated_at: now,
|
||||||
|
updated_by: 'System'
|
||||||
|
},
|
||||||
|
$push: { versions: versionEntry }
|
||||||
|
}
|
||||||
|
|
||||||
|
await skillsCollection.updateOne(
|
||||||
|
{ _id: skill._id },
|
||||||
|
updateData
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
action: 'updated',
|
||||||
|
name,
|
||||||
|
version: versionEntry.version,
|
||||||
|
updated_at: now
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Test update skill error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const totalSkills = await skillsCollection.countDocuments({ is_public: true })
|
||||||
|
const totalDownloads = await skillsCollection.aggregate([
|
||||||
|
{ $match: { is_public: true } },
|
||||||
|
{ $group: { _id: null, total: { $sum: '$downloads' } } }
|
||||||
|
]).toArray()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
stats: {
|
||||||
|
total_skills: totalSkills,
|
||||||
|
total_downloads: totalDownloads[0]?.total || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Get stats error:', err)
|
||||||
|
res.status(500).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
await connectDB()
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[Server] Skills Market API running on http://localhost:${PORT}`)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Server] Failed to start:', err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start()
|
||||||
59
services/auth.js
Normal file
59
services/auth.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
const { sendEmailCode } = require('./email')
|
||||||
|
|
||||||
|
function generateCode() {
|
||||||
|
return Math.floor(100000 + Math.random() * 900000).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendVerificationCode(db, email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return { success: false, error: '邮箱格式不正确' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const codesCollection = db.collection('verification_codes')
|
||||||
|
|
||||||
|
const recentCode = await codesCollection.findOne({
|
||||||
|
email,
|
||||||
|
created_at: { $gt: new Date(Date.now() - 60000) }
|
||||||
|
})
|
||||||
|
if (recentCode) {
|
||||||
|
return { success: false, error: '验证码发送太频繁,请稍后再试' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = generateCode()
|
||||||
|
const result = await sendEmailCode(email, code)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, error: '验证码发送失败: ' + result.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
await codesCollection.deleteMany({ email })
|
||||||
|
|
||||||
|
await codesCollection.insertOne({
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 5 * 60 * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyCode(db, email, code) {
|
||||||
|
const codesCollection = db.collection('verification_codes')
|
||||||
|
|
||||||
|
const record = await codesCollection.findOne({
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
expires_at: { $gt: new Date() }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return { success: false, error: '验证码无效或已过期' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await codesCollection.deleteMany({ email })
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendVerificationCode, verifyCode, generateCode }
|
||||||
56
services/email.js
Normal file
56
services/email.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
const Dm20151123 = require('@alicloud/dm20151123')
|
||||||
|
const OpenApi = require('@alicloud/openapi-client')
|
||||||
|
const TeaUtil = require('@alicloud/tea-util')
|
||||||
|
|
||||||
|
let client = null
|
||||||
|
|
||||||
|
function createClient() {
|
||||||
|
if (client) return client
|
||||||
|
|
||||||
|
const config = new OpenApi.Config({
|
||||||
|
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID,
|
||||||
|
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET,
|
||||||
|
})
|
||||||
|
config.endpoint = process.env.ALIYUN_DM_ENDPOINT || 'dm.aliyuncs.com'
|
||||||
|
client = new Dm20151123.default(config)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmailCode(toAddress, code) {
|
||||||
|
try {
|
||||||
|
console.log('[Email] Sending code to:', toAddress)
|
||||||
|
console.log('[Email] Using template ID:', process.env.ALIYUN_DM_TEMPLATE_ID)
|
||||||
|
console.log('[Email] Using account name:', process.env.ALIYUN_DM_ACCOUNT_NAME)
|
||||||
|
|
||||||
|
const dmClient = createClient()
|
||||||
|
|
||||||
|
const template = new Dm20151123.SingleSendMailRequestTemplate({
|
||||||
|
templateData: { code },
|
||||||
|
templateId: process.env.ALIYUN_DM_TEMPLATE_ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = new Dm20151123.SingleSendMailRequest({
|
||||||
|
template,
|
||||||
|
accountName: process.env.ALIYUN_DM_ACCOUNT_NAME,
|
||||||
|
addressType: 1,
|
||||||
|
replyToAddress: false,
|
||||||
|
toAddress,
|
||||||
|
subject: process.env.ALIYUN_DM_DEFAULT_SUBJECT || '登录验证码',
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Email] Request prepared:', JSON.stringify(request, null, 2))
|
||||||
|
|
||||||
|
const runtime = new TeaUtil.RuntimeOptions({})
|
||||||
|
await dmClient.singleSendMailWithOptions(request, runtime)
|
||||||
|
|
||||||
|
console.log('[Email] Send success')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.message || String(error)
|
||||||
|
console.error('[Email] Send error:', message)
|
||||||
|
console.error('[Email] Error details:', error)
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendEmailCode }
|
||||||
Loading…
x
Reference in New Issue
Block a user