Initial commit

This commit is contained in:
hjjjj 2026-02-28 17:57:28 +08:00
commit a8a5f6e866
9 changed files with 2504 additions and 0 deletions

13
.env.example Normal file
View 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
View File

@ -0,0 +1,3 @@
node_modules/
.env
*.log

88
README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View 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
View 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
View 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
View 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
View 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 }