Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47bf6f48d5 | ||
|
|
cb4b7d5363 |
@@ -4,6 +4,7 @@ __pycache__/
|
|||||||
|
|
||||||
# 数据
|
# 数据
|
||||||
data/*.json
|
data/*.json
|
||||||
|
data/*.db
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
|
||||||
# 上传文件
|
# 上传文件
|
||||||
@@ -13,3 +14,6 @@ uploads/*
|
|||||||
# 环境
|
# 环境
|
||||||
venv/
|
venv/
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# 本地配置(敏感信息)
|
||||||
|
config.local.py
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
- 手机号(可选)
|
- 手机号(可选)
|
||||||
- 密码确认
|
- 密码确认
|
||||||
|
|
||||||
|
### 🔐 后台管理
|
||||||
|
- 管理员登录验证
|
||||||
|
- 用户管理
|
||||||
|
- 帖子管理(置顶、删除)
|
||||||
|
- 主题管理
|
||||||
|
- 数据统计
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 安装依赖
|
### 安装依赖
|
||||||
@@ -30,6 +37,20 @@
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 配置(可选)
|
||||||
|
|
||||||
|
可以创建 `config.local.py` 覆盖默认配置:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 管理员账户
|
||||||
|
ADMIN_USERNAME = 'your_admin'
|
||||||
|
ADMIN_PASSWORD = 'your_password'
|
||||||
|
|
||||||
|
# 或使用环境变量
|
||||||
|
export TECH_FORUM_ADMIN_USER='your_admin'
|
||||||
|
export TECH_FORUM_ADMIN_PASS='your_password'
|
||||||
|
```
|
||||||
|
|
||||||
### 启动主服务
|
### 启动主服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -46,12 +67,16 @@ python admin/app.py
|
|||||||
|
|
||||||
后台地址: http://localhost:19005
|
后台地址: http://localhost:19005
|
||||||
|
|
||||||
|
默认账号: admin / admin123
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
tech-forum/
|
tech-forum/
|
||||||
|
├── config.py # 配置文件
|
||||||
|
├── models.py # 数据库模型(SQLite)
|
||||||
├── backend/
|
├── backend/
|
||||||
│ └── app.py # Flask�端
|
│ └── app.py # Flask后端API
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── index.html # 首页
|
│ ├── index.html # 首页
|
||||||
│ ├── login.html # 登录
|
│ ├── login.html # 登录
|
||||||
@@ -60,12 +85,17 @@ tech-forum/
|
|||||||
│ ├── post.html # 帖子详情
|
│ ├── post.html # 帖子详情
|
||||||
│ ├── topic.html # 主题详情
|
│ ├── topic.html # 主题详情
|
||||||
│ └── user.html # 用户主页
|
│ └── user.html # 用户主页
|
||||||
|
├── admin/
|
||||||
|
│ ├── app.py # 后台管理(带登录验证)
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── index.html # 仪表盘
|
||||||
|
│ ├── login.html # 管理员登录
|
||||||
|
│ ├── users.html # 用户管理
|
||||||
|
│ ├── posts.html # 帖子管理
|
||||||
|
│ └── topics.html # 主题管理
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── users.json # 用户数�
|
│ └ tech_forum.db # SQLite数据库(自动创建)
|
||||||
│ ├── posts.json # 帖å�æ•°æ�®
|
└── uploads/ # 上传文件
|
||||||
│ └── topics.json # 主题数�
|
|
||||||
├── uploads/ # ä¸Šä¼ æ–‡ä»¶
|
|
||||||
└── README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API接口
|
## API接口
|
||||||
@@ -95,8 +125,38 @@ tech-forum/
|
|||||||
- GET /api/tags - 获取热门标签
|
- GET /api/tags - 获取热门标签
|
||||||
- GET /api/search - 搜索
|
- GET /api/search - 搜索
|
||||||
|
|
||||||
|
## 安全改进
|
||||||
|
|
||||||
|
### v1.1.0 重构内容
|
||||||
|
|
||||||
|
1. **后台登录验证**
|
||||||
|
- 所有后台API需要管理员登录
|
||||||
|
- Session + JWT双重验证
|
||||||
|
- 未登录自动跳转登录页
|
||||||
|
|
||||||
|
2. **配置文件分离**
|
||||||
|
- 敏感信息移至 `config.py`
|
||||||
|
- 支持环境变量覆盖
|
||||||
|
- SECRET_KEY 自动随机生成
|
||||||
|
|
||||||
|
3. **SQLite数据库**
|
||||||
|
- 替换JSON文件存储
|
||||||
|
- 解决并发写入问题
|
||||||
|
- 数据关系完整性
|
||||||
|
|
||||||
|
4. **API密钥保护**
|
||||||
|
- LLM密钥从代码移至配置
|
||||||
|
- 支持环境变量设置
|
||||||
|
|
||||||
## 版本历史
|
## 版本历史
|
||||||
|
|
||||||
|
### v1.1.0 (2026-04-12)
|
||||||
|
- 重构:后台添加登录验证
|
||||||
|
- 重构:JSON存储改为SQLite数据库
|
||||||
|
- 重构:API密钥移至配置文件
|
||||||
|
- 修复:SECRET_KEY改为随机生成
|
||||||
|
- 新增:管理员登录页面
|
||||||
|
|
||||||
### v0.1.0 (2026-04-08)
|
### v0.1.0 (2026-04-08)
|
||||||
- 初始版本
|
- 初始版本
|
||||||
- 技术交流帖子功能
|
- 技术交流帖子功能
|
||||||
@@ -106,9 +166,4 @@ tech-forum/
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT™»å½•
|
|
||||||
- 评论回�点赞
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
+172
-171
@@ -1,90 +1,156 @@
|
|||||||
"""
|
"""
|
||||||
技术论坛 - 后台管理系统
|
技术论坛 - 后台管理系统 (重构版 + 登录验证)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Flask, render_template, jsonify, request
|
from flask import Flask, render_template, jsonify, request, redirect, url_for, session, make_response
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
import json
|
import jwt
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
|
||||||
|
# 导入配置和模型
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
from config import SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD, DATABASE_PATH, ADMIN_PORT
|
||||||
|
from models import Database, UserModel, PostModel, ReplyModel, TopicModel
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
app.secret_key = SECRET_KEY
|
||||||
|
|
||||||
# 数据目录
|
# 初始化数据库
|
||||||
DATA_DIR = Path(__file__).parent.parent / 'data'
|
db = Database(DATABASE_PATH)
|
||||||
USERS_FILE = DATA_DIR / 'users.json'
|
user_model = UserModel(db)
|
||||||
POSTS_FILE = DATA_DIR / 'posts.json'
|
post_model = PostModel(db)
|
||||||
TOPICS_FILE = DATA_DIR / 'topics.json'
|
reply_model = ReplyModel(db)
|
||||||
|
topic_model = TopicModel(db)
|
||||||
|
|
||||||
def load_users():
|
# ============ 登录验证装饰器 ============
|
||||||
if USERS_FILE.exists():
|
|
||||||
return json.loads(USERS_FILE.read_text(encoding='utf-8'))
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def load_posts():
|
def admin_required(f):
|
||||||
if POSTS_FILE.exists():
|
@wraps(f)
|
||||||
return json.loads(POSTS_FILE.read_text(encoding='utf-8'))
|
def decorated_function(*args, **kwargs):
|
||||||
return {}
|
# 检查 session
|
||||||
|
if not session.get('admin_logged_in'):
|
||||||
|
# 检查 Authorization header
|
||||||
|
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
|
||||||
|
if data.get('admin'):
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
def load_topics():
|
# API请求返回401,页面请求跳转登录
|
||||||
if TOPICS_FILE.exists():
|
if request.path.startswith('/api/'):
|
||||||
return json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
return jsonify({'error': '请先登录', 'code': 401}), 401
|
||||||
return {}
|
return redirect('/login')
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
def save_users(users):
|
# ============ 登录相关 ============
|
||||||
USERS_FILE.write_text(json.dumps(users, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
||||||
|
|
||||||
def save_posts(posts):
|
@app.route('/login')
|
||||||
POSTS_FILE.write_text(json.dumps(posts, ensure_ascii=False, indent=2), encoding='utf-8')
|
def login_page():
|
||||||
|
return render_template('login.html')
|
||||||
|
|
||||||
def save_topics(topics):
|
@app.route('/api/login', methods=['POST'])
|
||||||
TOPICS_FILE.write_text(json.dumps(topics, ensure_ascii=False, indent=2), encoding='utf-8')
|
def api_login():
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
password = data.get('password', '')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({'error': '请输入用户名和密码'}), 400
|
||||||
|
|
||||||
|
if username != ADMIN_USERNAME or password != ADMIN_PASSWORD:
|
||||||
|
return jsonify({'error': '用户名或密码错误'}), 400
|
||||||
|
|
||||||
|
# 设置session
|
||||||
|
session['admin_logged_in'] = True
|
||||||
|
session['admin_username'] = username
|
||||||
|
|
||||||
|
# 生成token(可选,用于API调用)
|
||||||
|
token = jwt.encode({
|
||||||
|
'admin': True,
|
||||||
|
'username': username,
|
||||||
|
'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)
|
||||||
|
}, SECRET_KEY, algorithm='HS256')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'token': token,
|
||||||
|
'message': '登录成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/logout', methods=['POST'])
|
||||||
|
def api_logout():
|
||||||
|
session.pop('admin_logged_in', None)
|
||||||
|
session.pop('admin_username', None)
|
||||||
|
return jsonify({'success': True, 'message': '已退出登录'})
|
||||||
|
|
||||||
|
@app.route('/api/check-auth')
|
||||||
|
def api_check_auth():
|
||||||
|
if session.get('admin_logged_in'):
|
||||||
|
return jsonify({
|
||||||
|
'logged_in': True,
|
||||||
|
'username': session.get('admin_username')
|
||||||
|
})
|
||||||
|
return jsonify({'logged_in': False})
|
||||||
|
|
||||||
# ============ 页面路由 ============
|
# ============ 页面路由 ============
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
@admin_required
|
||||||
def index():
|
def index():
|
||||||
return render_template('index.html')
|
return render_template('index.html')
|
||||||
|
|
||||||
@app.route('/users')
|
@app.route('/users')
|
||||||
|
@admin_required
|
||||||
def users_page():
|
def users_page():
|
||||||
return render_template('users.html')
|
return render_template('users.html')
|
||||||
|
|
||||||
@app.route('/posts')
|
@app.route('/posts')
|
||||||
|
@admin_required
|
||||||
def posts_page():
|
def posts_page():
|
||||||
return render_template('posts.html')
|
return render_template('posts.html')
|
||||||
|
|
||||||
@app.route('/topics')
|
@app.route('/topics')
|
||||||
|
@admin_required
|
||||||
def topics_page():
|
def topics_page():
|
||||||
return render_template('topics.html')
|
return render_template('topics.html')
|
||||||
|
|
||||||
# ============ API路由 ============
|
# ============ API路由 ============
|
||||||
|
|
||||||
@app.route('/api/stats')
|
@app.route('/api/stats')
|
||||||
|
@admin_required
|
||||||
def api_stats():
|
def api_stats():
|
||||||
users = load_users()
|
users = user_model.get_all()
|
||||||
posts = load_posts()
|
posts, posts_total = post_model.get_all()
|
||||||
topics = load_topics()
|
topics = topic_model.get_all()
|
||||||
|
|
||||||
# 统计
|
# 统计回复数
|
||||||
total_messages = 0
|
total_replies = 0
|
||||||
for post in posts.values():
|
for post in posts:
|
||||||
total_messages += len(post.get('replies', []))
|
total_replies += len(reply_model.get_by_post(post['id']))
|
||||||
|
|
||||||
today = datetime.now().strftime('%Y-%m-%d')
|
today = datetime.datetime.now().strftime('%Y-%m-%d')
|
||||||
today_posts = sum(1 for p in posts.values() if p.get('created_at', '').startswith(today))
|
today_posts = sum(1 for p in posts if p.get('created_at', '').startswith(today))
|
||||||
today_users = sum(1 for u in users.values() if u.get('created_at', '').startswith(today))
|
today_users = sum(1 for u in users if u.get('created_at', '').startswith(today))
|
||||||
|
|
||||||
# 帖子类型统计
|
# 帖子类型统计
|
||||||
discussion_count = sum(1 for p in posts.values() if p.get('type') == 'discussion')
|
discussion_count = sum(1 for p in posts if p.get('type') == 'discussion')
|
||||||
share_count = sum(1 for p in posts.values() if p.get('type') == 'share')
|
share_count = sum(1 for p in posts if p.get('type') == 'share')
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'users_count': len(users),
|
'users_count': len(users),
|
||||||
'posts_count': len(posts),
|
'posts_count': posts_total,
|
||||||
'topics_count': len(topics),
|
'topics_count': len(topics),
|
||||||
'messages_count': total_messages,
|
'messages_count': total_replies,
|
||||||
'today_posts': today_posts,
|
'today_posts': today_posts,
|
||||||
'today_users': today_users,
|
'today_users': today_users,
|
||||||
'discussion_count': discussion_count,
|
'discussion_count': discussion_count,
|
||||||
@@ -92,110 +158,74 @@ def api_stats():
|
|||||||
})
|
})
|
||||||
|
|
||||||
@app.route('/api/users')
|
@app.route('/api/users')
|
||||||
|
@admin_required
|
||||||
def api_users():
|
def api_users():
|
||||||
users = load_users()
|
users = user_model.get_all()
|
||||||
posts = load_posts()
|
|
||||||
|
|
||||||
user_list = []
|
user_list = []
|
||||||
for uid, user in users.items():
|
for user in users:
|
||||||
# 统计用户帖子和回复数
|
|
||||||
posts_count = len(user.get('posts', []))
|
|
||||||
replies_count = 0
|
|
||||||
for post in posts.values():
|
|
||||||
for reply in post.get('replies', []):
|
|
||||||
if reply.get('author_id') == uid:
|
|
||||||
replies_count += 1
|
|
||||||
|
|
||||||
user_list.append({
|
user_list.append({
|
||||||
'id': uid,
|
'id': user['id'],
|
||||||
'username': user.get('username', ''),
|
'username': user.get('username', ''),
|
||||||
'email': user.get('email', ''),
|
'email': user.get('email', ''),
|
||||||
'phone': user.get('phone', ''),
|
'phone': user.get('phone', ''),
|
||||||
'posts_count': posts_count,
|
'posts_count': user_model.get_posts_count(user['id']),
|
||||||
'replies_count': replies_count,
|
'replies_count': user_model.get_replies_count(user['id']),
|
||||||
'created_at': user.get('created_at', ''),
|
'created_at': user.get('created_at', ''),
|
||||||
})
|
})
|
||||||
|
|
||||||
user_list.sort(key=lambda x: x['created_at'], reverse=True)
|
|
||||||
return jsonify(user_list)
|
return jsonify(user_list)
|
||||||
|
|
||||||
@app.route('/api/users/<user_id>', methods=['DELETE'])
|
@app.route('/api/users/<user_id>', methods=['DELETE'])
|
||||||
|
@admin_required
|
||||||
def api_delete_user(user_id):
|
def api_delete_user(user_id):
|
||||||
users = load_users()
|
user_model.delete(user_id)
|
||||||
posts = load_posts()
|
|
||||||
topics = load_topics()
|
|
||||||
|
|
||||||
if user_id not in users:
|
|
||||||
return jsonify({'error': '用户不存在'}), 404
|
|
||||||
|
|
||||||
# 删除用户的帖子
|
|
||||||
for post_id in users[user_id].get('posts', []):
|
|
||||||
if post_id in posts:
|
|
||||||
del posts[post_id]
|
|
||||||
|
|
||||||
# 从主题关注中移除
|
|
||||||
for topic in topics.values():
|
|
||||||
if user_id in topic.get('followers', []):
|
|
||||||
topic['followers'].remove(user_id)
|
|
||||||
|
|
||||||
# 删除用户
|
|
||||||
del users[user_id]
|
|
||||||
|
|
||||||
save_users(users)
|
|
||||||
save_posts(posts)
|
|
||||||
save_topics(topics)
|
|
||||||
|
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
|
|
||||||
@app.route('/api/posts')
|
@app.route('/api/posts')
|
||||||
|
@admin_required
|
||||||
def api_posts():
|
def api_posts():
|
||||||
posts = load_posts()
|
|
||||||
users = load_users()
|
|
||||||
|
|
||||||
post_type = request.args.get('type')
|
post_type = request.args.get('type')
|
||||||
|
|
||||||
post_list = []
|
posts, total = post_model.get_all(post_type)
|
||||||
for pid, post in posts.items():
|
|
||||||
if post_type and post['type'] != post_type:
|
|
||||||
continue
|
|
||||||
|
|
||||||
author = users.get(post['author_id'], {})
|
post_list = []
|
||||||
|
for post in posts:
|
||||||
|
author = user_model.get_by_id(post['author_id']) or {}
|
||||||
post_list.append({
|
post_list.append({
|
||||||
'id': pid,
|
'id': post['id'],
|
||||||
'title': post['title'],
|
'title': post['title'],
|
||||||
'type': post['type'],
|
'type': post['type'],
|
||||||
'author': author.get('username', '未知'),
|
'author': author.get('username', '未知'),
|
||||||
'author_id': post['author_id'],
|
'author_id': post['author_id'],
|
||||||
'likes': len(post.get('likes', [])),
|
'likes': len(post['likes']),
|
||||||
'replies': len(post.get('replies', [])),
|
'replies': len(reply_model.get_by_post(post['id'])),
|
||||||
'views': post.get('views', 0),
|
'views': post['views'],
|
||||||
'is_pinned': post.get('is_pinned', False),
|
'is_pinned': post['is_pinned'],
|
||||||
'created_at': post['created_at'],
|
'created_at': post['created_at'],
|
||||||
})
|
})
|
||||||
|
|
||||||
post_list.sort(key=lambda x: x['created_at'], reverse=True)
|
|
||||||
return jsonify(post_list)
|
return jsonify(post_list)
|
||||||
|
|
||||||
@app.route('/api/posts/<post_id>')
|
@app.route('/api/posts/<post_id>')
|
||||||
|
@admin_required
|
||||||
def api_post_detail(post_id):
|
def api_post_detail(post_id):
|
||||||
posts = load_posts()
|
post = post_model.get_by_id(post_id)
|
||||||
users = load_users()
|
|
||||||
|
|
||||||
post = posts.get(post_id)
|
|
||||||
if not post:
|
if not post:
|
||||||
return jsonify({'error': '帖子不存在'}), 404
|
return jsonify({'error': '帖子不存在'}), 404
|
||||||
|
|
||||||
author = users.get(post['author_id'], {})
|
author = user_model.get_by_id(post['author_id']) or {}
|
||||||
|
|
||||||
# 获取回复
|
# 获取回复
|
||||||
replies = []
|
replies = reply_model.get_by_post(post_id)
|
||||||
for reply in post.get('replies', []):
|
reply_list = []
|
||||||
reply_author = users.get(reply['author_id'], {})
|
for reply in replies:
|
||||||
replies.append({
|
reply_author = user_model.get_by_id(reply['author_id']) or {}
|
||||||
|
reply_list.append({
|
||||||
'id': reply['id'],
|
'id': reply['id'],
|
||||||
'content': reply['content'][:100] + '...' if len(reply['content']) > 100 else reply['content'],
|
'content': reply['content'][:100] + '...' if len(reply['content']) > 100 else reply['content'],
|
||||||
'author': reply_author.get('username', '未知'),
|
'author': reply_author.get('username', '未知'),
|
||||||
'likes': len(reply.get('likes', [])),
|
'likes': len(reply['likes']),
|
||||||
'created_at': reply['created_at'],
|
'created_at': reply['created_at'],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -205,83 +235,58 @@ def api_post_detail(post_id):
|
|||||||
'content': post['content'],
|
'content': post['content'],
|
||||||
'type': post['type'],
|
'type': post['type'],
|
||||||
'author': author.get('username', '未知'),
|
'author': author.get('username', '未知'),
|
||||||
'tags': post.get('tags', []),
|
'tags': post['tags'],
|
||||||
'likes': len(post.get('likes', [])),
|
'likes': len(post['likes']),
|
||||||
'replies': replies,
|
'replies': reply_list,
|
||||||
'views': post.get('views', 0),
|
'views': post['views'],
|
||||||
'is_pinned': post.get('is_pinned', False),
|
'is_pinned': post['is_pinned'],
|
||||||
'created_at': post['created_at'],
|
'created_at': post['created_at'],
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route('/api/posts/<post_id>', methods=['DELETE'])
|
@app.route('/api/posts/<post_id>', methods=['DELETE'])
|
||||||
|
@admin_required
|
||||||
def api_delete_post(post_id):
|
def api_delete_post(post_id):
|
||||||
posts = load_posts()
|
post_model.delete(post_id)
|
||||||
users = load_users()
|
|
||||||
|
|
||||||
if post_id not in posts:
|
|
||||||
return jsonify({'error': '帖子不存在'}), 404
|
|
||||||
|
|
||||||
author_id = posts[post_id].get('author_id')
|
|
||||||
|
|
||||||
del posts[post_id]
|
|
||||||
|
|
||||||
# 从用户列表中移除
|
|
||||||
if author_id and author_id in users:
|
|
||||||
if post_id in users[author_id].get('posts', []):
|
|
||||||
users[author_id]['posts'].remove(post_id)
|
|
||||||
|
|
||||||
save_posts(posts)
|
|
||||||
save_users(users)
|
|
||||||
|
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
|
|
||||||
@app.route('/api/posts/<post_id>/pin', methods=['POST'])
|
@app.route('/api/posts/<post_id>/pin', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
def api_pin_post(post_id):
|
def api_pin_post(post_id):
|
||||||
posts = load_posts()
|
new_pin = post_model.toggle_pin(post_id)
|
||||||
|
|
||||||
if post_id not in posts:
|
|
||||||
return jsonify({'error': '帖子不存在'}), 404
|
|
||||||
|
|
||||||
posts[post_id]['is_pinned'] = not posts[post_id].get('is_pinned', False)
|
|
||||||
save_posts(posts)
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'is_pinned': posts[post_id]['is_pinned']
|
'is_pinned': new_pin
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route('/api/topics')
|
@app.route('/api/topics')
|
||||||
|
@admin_required
|
||||||
def api_topics():
|
def api_topics():
|
||||||
topics = load_topics()
|
topics = topic_model.get_all()
|
||||||
users = load_users()
|
|
||||||
|
|
||||||
topic_list = []
|
topic_list = []
|
||||||
for tid, topic in topics.items():
|
for topic in topics:
|
||||||
author = users.get(topic['author_id'], {})
|
author = user_model.get_by_id(topic['author_id']) or {}
|
||||||
topic_list.append({
|
topic_list.append({
|
||||||
'id': tid,
|
'id': topic['id'],
|
||||||
'name': topic['name'],
|
'name': topic['name'],
|
||||||
'icon': topic.get('icon', '🔧'),
|
'icon': topic.get('icon', '🔧'),
|
||||||
'author': author.get('username', '未知'),
|
'author': author.get('username', '未知'),
|
||||||
'sub_topics_count': len(topic.get('sub_topics', [])),
|
'sub_topics_count': len(topic_model.get_sub_topics(topic['id'])),
|
||||||
'questions_count': len(topic.get('questions', [])),
|
'questions_count': len(topic_model.get_questions(topic['id'])),
|
||||||
'followers_count': len(topic.get('followers', [])),
|
'followers_count': len(topic['followers']),
|
||||||
'created_at': topic['created_at'],
|
'created_at': topic['created_at'],
|
||||||
})
|
})
|
||||||
|
|
||||||
topic_list.sort(key=lambda x: x['followers_count'], reverse=True)
|
|
||||||
return jsonify(topic_list)
|
return jsonify(topic_list)
|
||||||
|
|
||||||
@app.route('/api/topics/<topic_id>')
|
@app.route('/api/topics/<topic_id>')
|
||||||
|
@admin_required
|
||||||
def api_topic_detail(topic_id):
|
def api_topic_detail(topic_id):
|
||||||
topics = load_topics()
|
topic = topic_model.get_by_id(topic_id)
|
||||||
users = load_users()
|
|
||||||
|
|
||||||
topic = topics.get(topic_id)
|
|
||||||
if not topic:
|
if not topic:
|
||||||
return jsonify({'error': '主题不存在'}), 404
|
return jsonify({'error': '主题不存在'}), 404
|
||||||
|
|
||||||
author = users.get(topic['author_id'], {})
|
author = user_model.get_by_id(topic['author_id']) or {}
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'id': topic_id,
|
'id': topic_id,
|
||||||
@@ -289,41 +294,37 @@ def api_topic_detail(topic_id):
|
|||||||
'description': topic.get('description', ''),
|
'description': topic.get('description', ''),
|
||||||
'icon': topic.get('icon', '🔧'),
|
'icon': topic.get('icon', '🔧'),
|
||||||
'author': author.get('username', '未知'),
|
'author': author.get('username', '未知'),
|
||||||
'sub_topics': topic.get('sub_topics', []),
|
'sub_topics': topic_model.get_sub_topics(topic_id),
|
||||||
'questions': topic.get('questions', []),
|
'questions': topic_model.get_questions(topic_id),
|
||||||
'followers_count': len(topic.get('followers', [])),
|
'followers_count': len(topic['followers']),
|
||||||
'created_at': topic['created_at'],
|
'created_at': topic['created_at'],
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route('/api/topics/<topic_id>', methods=['DELETE'])
|
@app.route('/api/topics/<topic_id>', methods=['DELETE'])
|
||||||
|
@admin_required
|
||||||
def api_delete_topic(topic_id):
|
def api_delete_topic(topic_id):
|
||||||
topics = load_topics()
|
topic_model.delete(topic_id)
|
||||||
|
|
||||||
if topic_id not in topics:
|
|
||||||
return jsonify({'error': '主题不存在'}), 404
|
|
||||||
|
|
||||||
del topics[topic_id]
|
|
||||||
save_topics(topics)
|
|
||||||
|
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
|
|
||||||
@app.route('/api/tags')
|
@app.route('/api/tags')
|
||||||
|
@admin_required
|
||||||
def api_tags():
|
def api_tags():
|
||||||
posts = load_posts()
|
tags = post_model.get_tags_stats()
|
||||||
|
|
||||||
tag_counts = {}
|
|
||||||
for post in posts.values():
|
|
||||||
for tag in post.get('tags', []):
|
|
||||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
||||||
|
|
||||||
tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)
|
|
||||||
return jsonify([{'name': t[0], 'count': t[1]} for t in tags[:20]])
|
return jsonify([{'name': t[0], 'count': t[1]} for t in tags[:20]])
|
||||||
|
|
||||||
|
# ============ 健康检查(无需登录) ============
|
||||||
|
|
||||||
|
@app.route('/api/health')
|
||||||
|
def api_health():
|
||||||
|
return jsonify({'status': 'ok', 'service': 'tech-forum-admin', 'port': ADMIN_PORT})
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print("技术论坛 - 后台管理系统")
|
print("技术论坛 - 后台管理系统")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print(f"访问地址: http://localhost:19005")
|
print(f"访问地址: http://localhost:{ADMIN_PORT}")
|
||||||
|
print(f"默认账号: {ADMIN_USERNAME}")
|
||||||
|
print(f"默认密码: {ADMIN_PASSWORD}")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
app.run(host='0.0.0.0', port=19005, debug=True)
|
app.run(host='0.0.0.0', port=ADMIN_PORT, debug=True)
|
||||||
@@ -21,23 +21,26 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav class="mt-6">
|
<nav class="mt-6">
|
||||||
<a href="/" class="flex items-center gap-3 px-6 py-3 bg-slate-700 text-white">
|
<a href="/admin" class="flex items-center gap-3 px-6 py-3 bg-slate-700 text-white">
|
||||||
<i class="ri-dashboard-line"></i><span>仪表盘</span>
|
<i class="ri-dashboard-line"></i><span>仪表盘</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/users" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
|
<a href="/admin/users" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
|
||||||
<i class="ri-user-line"></i><span>用户管理</span>
|
<i class="ri-user-line"></i><span>用户管理</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/posts" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
|
<a href="/admin/posts" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
|
||||||
<i class="ri-file-text-line"></i><span>帖子管理</span>
|
<i class="ri-file-text-line"></i><span>帖子管理</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/topics" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
|
<a href="/admin/topics" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
|
||||||
<i class="ri-tools-line"></i><span>主题管理</span>
|
<i class="ri-tools-line"></i><span>主题管理</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-700">
|
<div class="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-700">
|
||||||
<a href="http://localhost:19004" target="_blank" class="text-slate-400 hover:text-white text-sm flex items-center gap-2">
|
<a href="/" target="_blank" class="text-slate-400 hover:text-white text-sm flex items-center gap-2">
|
||||||
<i class="ri-external-link-line"></i> 访问前台
|
<i class="ri-external-link-line"></i> 访问前台
|
||||||
</a>
|
</a>
|
||||||
|
<button onclick="logout()" class="mt-2 text-slate-400 hover:text-red-400 text-sm flex items-center gap-2">
|
||||||
|
<i class="ri-logout-box-line"></i> 退出登录
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -140,8 +143,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// 检查登录状态
|
||||||
|
async function checkAuth() {
|
||||||
|
const res = await fetch('/admin/api/check-auth');
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.logged_in) {
|
||||||
|
window.location.href = '/admin/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/admin/api/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/admin/login';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
const res = await fetch('/api/stats');
|
const res = await fetch('/admin/api/stats');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
document.getElementById('stat-users').textContent = data.users_count;
|
document.getElementById('stat-users').textContent = data.users_count;
|
||||||
@@ -155,7 +174,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTags() {
|
async function loadTags() {
|
||||||
const res = await fetch('/api/tags');
|
const res = await fetch('/admin/api/tags');
|
||||||
const tags = await res.json();
|
const tags = await res.json();
|
||||||
|
|
||||||
const container = document.getElementById('tagsList');
|
const container = document.getElementById('tagsList');
|
||||||
@@ -172,7 +191,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRecentPosts() {
|
async function loadRecentPosts() {
|
||||||
const res = await fetch('/api/posts');
|
const res = await fetch('/admin/api/posts');
|
||||||
const posts = await res.json();
|
const posts = await res.json();
|
||||||
|
|
||||||
const container = document.getElementById('recentPosts');
|
const container = document.getElementById('recentPosts');
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>技术论坛 - 后台登录</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.gradient-bg { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="w-16 h-16 gradient-bg rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<i class="ri-shield-keyhole-line text-3xl text-white"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800">后台管理系统</h1>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">请输入管理员账号登录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="loginForm" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||||||
|
<div class="relative">
|
||||||
|
<i class="ri-user-line absolute left-3 top-3 text-gray-400"></i>
|
||||||
|
<input type="text" id="username" name="username" required
|
||||||
|
class="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="请输入用户名">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">密码</label>
|
||||||
|
<div class="relative">
|
||||||
|
<i class="ri-lock-line absolute left-3 top-3 text-gray-400"></i>
|
||||||
|
<input type="password" id="password" name="password" required
|
||||||
|
class="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="请输入密码">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorMsg" class="hidden text-red-500 text-sm text-center"></div>
|
||||||
|
|
||||||
|
<button type="submit" id="submitBtn"
|
||||||
|
class="w-full py-3 gradient-bg text-white rounded-lg font-medium hover:opacity-90 transition flex items-center justify-center gap-2">
|
||||||
|
<i class="ri-login-circle-line"></i>
|
||||||
|
<span>登录</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="http://localhost:19004" target="_blank" class="text-sm text-gray-500 hover:text-blue-600">
|
||||||
|
<i class="ri-external-link-line"></i> 访问前台网站
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 检查是否已登录
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/check-auth');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.logged_in) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 未登录,继续显示登录页面
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
// 登录表单提交
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const errorEl = document.getElementById('errorMsg');
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> 登录中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
window.location.href = '/admin';
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = data.error || '登录失败';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorEl.textContent = '网络错误,请稍后重试';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="ri-login-circle-line"></i> <span>登录</span>';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>>
|
||||||
+428
-333
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
技术论坛配置文件
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
# 安全密钥(生产环境应使用环境变量)
|
||||||
|
SECRET_KEY = os.environ.get('TECH_FORUM_SECRET', secrets.token_hex(32))
|
||||||
|
|
||||||
|
# 管理员账户(后台登录)
|
||||||
|
ADMIN_USERNAME = os.environ.get('TECH_FORUM_ADMIN_USER', 'admin')
|
||||||
|
ADMIN_PASSWORD = os.environ.get('TECH_FORUM_ADMIN_PASS', 'admin123')
|
||||||
|
|
||||||
|
# LLM 配置(可选,用于AI功能)
|
||||||
|
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'http://192.168.2.5:1234/v1')
|
||||||
|
LLM_API_KEY = os.environ.get('LLM_API_KEY', '')
|
||||||
|
LLM_MODEL = os.environ.get('LLM_MODEL', 'qwen3.5-4b')
|
||||||
|
|
||||||
|
# 数据库路径
|
||||||
|
DATABASE_PATH = os.environ.get('TECH_FORUM_DB', 'data/tech_forum.db')
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
BACKEND_PORT = 19004
|
||||||
|
ADMIN_PORT = 19005
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
"""
|
||||||
|
技术论坛数据库模型 - SQLite
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_path='data/tech_forum.db'):
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._init_tables()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_conn(self):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _init_tables(self):
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
# 用户表
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
avatar TEXT,
|
||||||
|
bio TEXT,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# 帖子表
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
type TEXT DEFAULT 'discussion',
|
||||||
|
author_id TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
likes TEXT DEFAULT '[]',
|
||||||
|
views INTEGER DEFAULT 0,
|
||||||
|
is_pinned INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# 回复表
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS replies (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
post_id TEXT,
|
||||||
|
content TEXT,
|
||||||
|
author_id TEXT,
|
||||||
|
likes TEXT DEFAULT '[]',
|
||||||
|
reply_to TEXT,
|
||||||
|
created_at TEXT,
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts(id),
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# 主题表(工具分享)
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT DEFAULT '🔧',
|
||||||
|
author_id TEXT,
|
||||||
|
followers TEXT DEFAULT '[]',
|
||||||
|
created_at TEXT,
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# 子主题表
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS sub_topics (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
topic_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
content TEXT,
|
||||||
|
author_id TEXT,
|
||||||
|
created_at TEXT,
|
||||||
|
FOREIGN KEY (topic_id) REFERENCES topics(id),
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# 问题表
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
topic_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
content TEXT,
|
||||||
|
author_id TEXT,
|
||||||
|
views INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT,
|
||||||
|
FOREIGN KEY (topic_id) REFERENCES topics(id),
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# 回答表
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS answers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
question_id TEXT,
|
||||||
|
content TEXT,
|
||||||
|
author_id TEXT,
|
||||||
|
likes TEXT DEFAULT '[]',
|
||||||
|
created_at TEXT,
|
||||||
|
FOREIGN KEY (question_id) REFERENCES questions(id),
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class UserModel:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create(self, username, email, phone, password):
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
avatar = f'https://api.dicebear.com/7.x/avataaars/svg?seed={username}'
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO users (id, username, email, phone, password, avatar, bio, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, '', ?, ?)
|
||||||
|
''', (user_id, username, email, phone, generate_password_hash(password), avatar, now, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
def get_by_id(self, user_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
row = conn.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_by_username(self, username):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
row = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_by_email(self, email):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
row = conn.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def verify_password(self, user, password):
|
||||||
|
return check_password_hash(user['password'], password)
|
||||||
|
|
||||||
|
def get_all(self):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
rows = conn.execute('SELECT * FROM users ORDER BY created_at DESC').fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def delete(self, user_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
# 删除用户的所有帖子
|
||||||
|
conn.execute('DELETE FROM replies WHERE author_id = ?', (user_id,))
|
||||||
|
conn.execute('DELETE FROM posts WHERE author_id = ?', (user_id,))
|
||||||
|
conn.execute('DELETE FROM users WHERE id = ?', (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_posts_count(self, user_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
return conn.execute('SELECT COUNT(*) FROM posts WHERE author_id = ?', (user_id,)).fetchone()[0]
|
||||||
|
|
||||||
|
def get_replies_count(self, user_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
return conn.execute('SELECT COUNT(*) FROM replies WHERE author_id = ?', (user_id,)).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
class PostModel:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create(self, title, content, post_type, author_id, tags):
|
||||||
|
post_id = str(uuid.uuid4())
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
tags_json = json.dumps(tags)
|
||||||
|
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO posts (id, title, content, type, author_id, tags, likes, views, is_pinned, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, '[]', 0, 0, ?, ?)
|
||||||
|
''', (post_id, title, content, post_type, author_id, tags_json, now, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return post_id
|
||||||
|
|
||||||
|
def get_by_id(self, post_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
row = conn.execute('SELECT * FROM posts WHERE id = ?', (post_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
post = dict(row)
|
||||||
|
post['tags'] = json.loads(post['tags'] or '[]')
|
||||||
|
post['likes'] = json.loads(post['likes'] or '[]')
|
||||||
|
return post
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all(self, post_type=None, tag=None, page=1, per_page=20):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
query = 'SELECT * FROM posts WHERE 1=1'
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if post_type:
|
||||||
|
query += ' AND type = ?'
|
||||||
|
params.append(post_type)
|
||||||
|
|
||||||
|
query += ' ORDER BY is_pinned DESC, created_at DESC'
|
||||||
|
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
posts = []
|
||||||
|
for row in rows:
|
||||||
|
post = dict(row)
|
||||||
|
post['tags'] = json.loads(post['tags'] or '[]')
|
||||||
|
post['likes'] = json.loads(post['likes'] or '[]')
|
||||||
|
posts.append(post)
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
start = (page - 1) * per_page
|
||||||
|
return posts[start:start + per_page], len(posts)
|
||||||
|
|
||||||
|
def increment_views(self, post_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('UPDATE posts SET views = views + 1 WHERE id = ?', (post_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def add_like(self, post_id, user_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
post = self.get_by_id(post_id)
|
||||||
|
likes = post['likes']
|
||||||
|
|
||||||
|
if user_id in likes:
|
||||||
|
likes.remove(user_id)
|
||||||
|
liked = False
|
||||||
|
else:
|
||||||
|
likes.append(user_id)
|
||||||
|
liked = True
|
||||||
|
|
||||||
|
conn.execute('UPDATE posts SET likes = ? WHERE id = ?', (json.dumps(likes), post_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return liked, len(likes)
|
||||||
|
|
||||||
|
def delete(self, post_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('DELETE FROM replies WHERE post_id = ?', (post_id,))
|
||||||
|
conn.execute('DELETE FROM posts WHERE id = ?', (post_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def toggle_pin(self, post_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
row = conn.execute('SELECT is_pinned FROM posts WHERE id = ?', (post_id,)).fetchone()
|
||||||
|
new_pin = 1 if row['is_pinned'] == 0 else 0
|
||||||
|
conn.execute('UPDATE posts SET is_pinned = ? WHERE id = ?', (new_pin, post_id))
|
||||||
|
conn.commit()
|
||||||
|
return new_pin
|
||||||
|
|
||||||
|
def get_tags_stats(self):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
rows = conn.execute('SELECT tags FROM posts').fetchall()
|
||||||
|
tag_counts = {}
|
||||||
|
for row in rows:
|
||||||
|
tags = json.loads(row['tags'] or '[]')
|
||||||
|
for tag in tags:
|
||||||
|
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||||
|
return sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ReplyModel:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create(self, post_id, content, author_id, reply_to=None):
|
||||||
|
reply_id = str(uuid.uuid4())
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO replies (id, post_id, content, author_id, likes, reply_to, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, '[]', ?, ?)
|
||||||
|
''', (reply_id, post_id, content, author_id, reply_to, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return reply_id
|
||||||
|
|
||||||
|
def get_by_post(self, post_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
rows = conn.execute('SELECT * FROM replies WHERE post_id = ? ORDER BY created_at', (post_id,)).fetchall()
|
||||||
|
replies = []
|
||||||
|
for row in rows:
|
||||||
|
reply = dict(row)
|
||||||
|
reply['likes'] = json.loads(reply['likes'] or '[]')
|
||||||
|
replies.append(reply)
|
||||||
|
return replies
|
||||||
|
|
||||||
|
|
||||||
|
class TopicModel:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create(self, name, description, icon, author_id):
|
||||||
|
topic_id = str(uuid.uuid4())
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO topics (id, name, description, icon, author_id, followers, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, '[]', ?)
|
||||||
|
''', (topic_id, name, description, icon, author_id, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return topic_id
|
||||||
|
|
||||||
|
def get_by_id(self, topic_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
row = conn.execute('SELECT * FROM topics WHERE id = ?', (topic_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
topic = dict(row)
|
||||||
|
topic['followers'] = json.loads(topic['followers'] or '[]')
|
||||||
|
return topic
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all(self):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
rows = conn.execute('SELECT * FROM topics ORDER BY created_at DESC').fetchall()
|
||||||
|
topics = []
|
||||||
|
for row in rows:
|
||||||
|
topic = dict(row)
|
||||||
|
topic['followers'] = json.loads(topic['followers'] or '[]')
|
||||||
|
topics.append(topic)
|
||||||
|
return topics
|
||||||
|
|
||||||
|
def delete(self, topic_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('DELETE FROM answers WHERE question_id IN (SELECT id FROM questions WHERE topic_id = ?)', (topic_id,))
|
||||||
|
conn.execute('DELETE FROM questions WHERE topic_id = ?', (topic_id,))
|
||||||
|
conn.execute('DELETE FROM sub_topics WHERE topic_id = ?', (topic_id,))
|
||||||
|
conn.execute('DELETE FROM topics WHERE id = ?', (topic_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def add_follower(self, topic_id, user_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
topic = self.get_by_id(topic_id)
|
||||||
|
followers = topic['followers']
|
||||||
|
|
||||||
|
if user_id in followers:
|
||||||
|
followers.remove(user_id)
|
||||||
|
followed = False
|
||||||
|
else:
|
||||||
|
followers.append(user_id)
|
||||||
|
followed = True
|
||||||
|
|
||||||
|
conn.execute('UPDATE topics SET followers = ? WHERE id = ?', (json.dumps(followers), topic_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return followed, len(followers)
|
||||||
|
|
||||||
|
def get_sub_topics(self, topic_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
rows = conn.execute('SELECT * FROM sub_topics WHERE topic_id = ? ORDER BY created_at', (topic_id,)).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def add_sub_topic(self, topic_id, title, content, author_id):
|
||||||
|
sub_id = str(uuid.uuid4())
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO sub_topics (id, topic_id, title, content, author_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''', (sub_id, topic_id, title, content, author_id, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return sub_id
|
||||||
|
|
||||||
|
def get_questions(self, topic_id):
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
rows = conn.execute('SELECT * FROM questions WHERE topic_id = ? ORDER BY created_at DESC', (topic_id,)).fetchall()
|
||||||
|
questions = []
|
||||||
|
for row in rows:
|
||||||
|
q = dict(row)
|
||||||
|
# 获取回答
|
||||||
|
ans_rows = conn.execute('SELECT * FROM answers WHERE question_id = ? ORDER BY created_at', (q['id'],)).fetchall()
|
||||||
|
answers = []
|
||||||
|
for ans in ans_rows:
|
||||||
|
a = dict(ans)
|
||||||
|
a['likes'] = json.loads(a['likes'] or '[]')
|
||||||
|
answers.append(a)
|
||||||
|
q['answers'] = answers
|
||||||
|
questions.append(q)
|
||||||
|
return questions
|
||||||
|
|
||||||
|
def add_question(self, topic_id, title, content, author_id):
|
||||||
|
q_id = str(uuid.uuid4())
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO questions (id, topic_id, title, content, author_id, views, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||||
|
''', (q_id, topic_id, title, content, author_id, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return q_id
|
||||||
|
|
||||||
|
def add_answer(self, question_id, content, author_id):
|
||||||
|
ans_id = str(uuid.uuid4())
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
|
||||||
|
with self.db.get_conn() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO answers (id, question_id, content, author_id, likes, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, '[]', ?)
|
||||||
|
''', (ans_id, question_id, content, author_id, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ans_id
|
||||||
@@ -3,3 +3,4 @@ flask-cors>=4.0.0
|
|||||||
pyjwt>=2.8.0
|
pyjwt>=2.8.0
|
||||||
werkzeug>=2.3.0
|
werkzeug>=2.3.0
|
||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
|
sqlite3 # Python内置,无需安装
|
||||||
Reference in New Issue
Block a user