feat: initial commit - lightweight ticket system with Flask+SQLite

Features:
- User authentication (login/register)
- Ticket CRUD with status/priority/category
- Rich text description with paste-to-image
- File attachment upload (images/documents)
- Multi-select CC recipients
- Role-based permission control (admin/user)
- In-app notifications
- Statistics dashboard
- Gunicorn production deployment
This commit is contained in:
AI Agent
2026-07-02 15:31:27 +08:00
commit 26940c4574
14 changed files with 2497 additions and 0 deletions
+775
View File
@@ -0,0 +1,775 @@
"""
轻量级工单系统 - Flask + SQLite
增强版:分类 / 优先级 / 标签 / 统计看板 / 登录 / 站内通知
"""
from datetime import datetime, timezone
import os
import uuid
from werkzeug.security import generate_password_hash, check_password_hash
from flask import (
Flask, request, redirect, render_template, session, abort, url_for,
jsonify, send_from_directory,
)
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config["SECRET_KEY"] = "ticket-system-secret-key-change-in-production"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tickets.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["UPLOAD_FOLDER"] = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB
db = SQLAlchemy(app)
# ── 枚举定义 ──────────────────────────────────────────
STATUS = {
"open": "新建",
"in_progress": "进行中",
"pending": "待处理",
"resolved": "已完成",
"closed": "已关闭",
}
PRIORITY = {
"low": "",
"medium": "",
"high": "",
"urgent": "紧急",
}
CATEGORY = {
"bug": "技术故障",
"feature": "功能需求",
"consult": "咨询",
"incident": "事件处理",
"other": "其他",
}
# ── 数据模型 ──────────────────────────────────────────
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(200), nullable=False)
role = db.Column(db.String(20), default="user") # admin / user
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class Notification(db.Model):
__tablename__ = "notifications"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text)
ticket_id = db.Column(db.Integer) # 关联工单(可选)
read = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
user = db.relationship("User", backref=db.backref("notifications", lazy=True))
class Ticket(db.Model):
__tablename__ = "tickets"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text) # 支持 HTML(内联图片)
status = db.Column(db.String(20), default="open", nullable=False)
priority = db.Column(db.String(20), default="medium", nullable=False)
category = db.Column(db.String(20), default="other", nullable=False)
assignee = db.Column(db.String(100))
requester = db.Column(db.String(100))
cc_users = db.Column(db.String(500), default="") # 抄送人(逗号分隔)
tags = db.Column(db.String(500), default="")
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
comments = db.relationship(
"Comment", backref="ticket", lazy=True, cascade="all, delete-orphan",
)
class Comment(db.Model):
__tablename__ = "comments"
id = db.Column(db.Integer, primary_key=True)
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
author = db.Column(db.String(100), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
class Attachment(db.Model):
"""工单附件(图片 / 文件)"""
__tablename__ = "attachments"
id = db.Column(db.Integer, primary_key=True)
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
filename = db.Column(db.String(200), nullable=False) # 存储文件名(uuid
original_name = db.Column(db.String(300), nullable=False) # 原始文件名
content_type = db.Column(db.String(100)) # MIME type
size = db.Column(db.Integer, default=0) # 字节
is_image = db.Column(db.Boolean, default=False)
uploaded_by = db.Column(db.String(100))
uploaded_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
ticket = db.relationship("Ticket", backref=db.backref(
"attachments", lazy=True, cascade="all, delete-orphan",
))
# ── 工具函数 ──────────────────────────────────────────
def status_label(s):
return STATUS.get(s, s)
def priority_label(p):
return PRIORITY.get(p, p)
def category_label(c):
return CATEGORY.get(c, c)
def parse_tags(tags):
if not tags:
return []
return [t.strip() for t in tags.split(",") if t.strip()]
def current_user():
"""返回当前登录用户或 None"""
if "user_id" not in session:
return None
return db.session.get(User, session["user_id"])
def login_required(func):
"""登录守卫装饰器"""
def wrapper(*args, **kwargs):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
return func(*args, **kwargs)
wrapper.__name__ = func.__name__
return wrapper
def create_notification(user_id, title, content="", ticket_id=None):
"""为指定用户创建一条通知"""
n = Notification(
user_id=user_id,
title=title,
content=content,
ticket_id=ticket_id,
)
db.session.add(n)
def all_users():
"""返回所有用户列表,供下拉选择"""
return User.query.order_by(User.username).all()
def parse_cc(cc_str):
"""解析抄送人字段,返回列表"""
if not cc_str:
return []
return [u.strip() for u in cc_str.split(",") if u.strip()]
def can_access_ticket(user, ticket):
"""
权限控制:
- admin 可见所有工单
- 普通用户只能看到:自己是提交人 / 处理人 / 抄送人的工单
"""
if user.role == "admin":
return True
if ticket.requester == user.username:
return True
if ticket.assignee == user.username:
return True
if user.username in parse_cc(ticket.cc_users):
return True
return False
def accessible_tickets_query(me):
"""
返回当前用户可见的工单 Query。
- admin: 全部
- 普通用户: 提交人=me OR 处理人=me OR 在抄送人里
"""
if me.role == "admin":
return Ticket.query
return Ticket.query.filter(
db.or_(
Ticket.requester == me.username,
Ticket.assignee == me.username,
Ticket.cc_users.like(f"%{me.username}%"),
),
)
def _save_upload(file, ticket_id):
"""保存上传文件,返回 Attachment 实例或 None"""
me = current_user()
if not file or file.filename == "":
return None
ext = os.path.splitext(file.filename)[1].lower()
safe_name = f"{uuid.uuid4().hex}{ext}"
user_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
os.makedirs(user_dir, exist_ok=True)
path = os.path.join(user_dir, safe_name)
file.save(path)
content_type = file.content_type or "application/octet-stream"
is_image = content_type.startswith("image/")
att = Attachment(
ticket_id=ticket_id,
filename=safe_name,
original_name=file.filename,
content_type=content_type,
size=os.path.getsize(path),
is_image=is_image,
uploaded_by=me.username if me else "system",
)
db.session.add(att)
db.session.commit()
return att
@app.route("/api/upload", methods=["POST"])
@login_required
def api_upload():
"""JSON API:上传文件(含二进制流 / 粘贴图片)"""
ticket_id = request.args.get("t")
if not ticket_id:
return jsonify({"error": "缺少 ticket 参数"}), 400
ticket = db.get_or_404(Ticket, int(ticket_id))
user = current_user()
if not can_access_ticket(user, ticket):
return jsonify({"error": "无权限"}), 403
# 1) 普通文件上传 (multipart)
if "file" in request.files:
att = _save_upload(request.files["file"], ticket_id)
# 2) 二进制流(粘贴图片、拖拽)
elif request.data:
import base64
data = request.data
# 尝试 base64 解码(前端 paste 用 base64 传)
try:
if request.headers.get("Content-Type", "").startswith("image/"):
raw = data
elif request.headers.get("Content-Type") == "application/octet-stream":
raw = data
else:
raw = data
ext = ".png"
# 猜测扩展名
ct = request.headers.get("Content-Type", "")
ext_map = {
"image/jpeg": ".jpg", "image/png": ".png",
"image/gif": ".gif", "image/webp": ".webp",
}
ext = ext_map.get(ct, ".png")
safe_name = f"{uuid.uuid4().hex}{ext}"
user_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
os.makedirs(user_dir, exist_ok=True)
path = os.path.join(user_dir, safe_name)
with open(path, "wb") as f:
f.write(raw)
att = Attachment(
ticket_id=ticket_id, filename=safe_name,
original_name="pasted-image.png", content_type=ct or "image/png",
size=os.path.getsize(path), is_image=True,
uploaded_by=current_user().username if current_user() else "system",
)
db.session.add(att)
db.session.commit()
except Exception as e:
return jsonify({"error": str(e)}), 500
else:
return jsonify({"error": "没有上传文件"}), 400
return jsonify({
"ok": True,
"url": f"/uploads/{ticket_id}/{att.filename}",
"filename": att.original_name,
"is_image": att.is_image,
})
@app.route("/api/upload/delete/<int:aid>", methods=["POST"])
@login_required
def api_delete_attachment(aid):
"""删除附件"""
att = db.get_or_404(Attachment, aid)
os.remove(os.path.join(app.config["UPLOAD_FOLDER"], str(att.ticket_id), att.filename))
db.session.delete(att)
db.session.commit()
return jsonify({"ok": True})
@app.route("/uploads/<int:ticket_id>/<path:filename>")
@login_required
def serve_upload(ticket_id, filename):
"""提供上传文件下载"""
ticket = db.get_or_404(Ticket, ticket_id)
user = current_user()
if not can_access_ticket(user, ticket):
abort(403)
safe_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
return send_from_directory(safe_dir, filename)
@app.route("/login", methods=["GET", "POST"])
def login():
if "user_id" in session:
return redirect(url_for("dashboard"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
session["user_id"] = user.id
session["username"] = user.username
next_page = request.args.get("next") or url_for("dashboard")
return redirect(next_page)
return render_template(
"login.html", error="用户名或密码错误", username=username,
)
return render_template("login.html")
@app.route("/register", methods=["GET", "POST"])
def register():
if "user_id" in session:
return redirect(url_for("dashboard"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
password2 = request.form.get("password2", "")
if not username or not password:
return render_template(
"register.html", error="用户名和密码不能为空", username=username,
)
if password != password2:
return render_template(
"register.html", error="两次输入的密码不一致", username=username,
)
if User.query.filter_by(username=username).first():
return render_template(
"register.html", error="用户名已存在", username=username,
)
user = User(username=username, role="user")
user.set_password(password)
db.session.add(user)
db.session.commit()
session["user_id"] = user.id
session["username"] = user.username
return redirect(url_for("dashboard"))
return render_template("register.html")
@app.route("/logout")
def logout():
session.pop("user_id", None)
session.pop("username", None)
return redirect(url_for("login"))
# ── 通知路由 ──────────────────────────────────────────
@app.route("/notifications")
@login_required
def notifications():
user = current_user()
notifs = (
Notification.query.filter_by(user_id=user.id)
.order_by(Notification.created_at.desc())
.limit(50)
.all()
)
# 标记为已读
for n in notifs:
n.read = True
db.session.commit()
return render_template(
"notifications.html", notifications=notifs, datetime=datetime,
status_label=status_label,
)
@app.route("/notifications/count")
def notifications_count():
"""返回未读通知数(供前端角标使用)"""
count = 0
if "user_id" in session:
count = Notification.query.filter_by(
user_id=session["user_id"], read=False,
).count()
return str(count)
# ── 工单路由(全部需要登录) ──────────────────────────
@app.route("/")
@login_required
def dashboard():
"""统计看板"""
user = current_user()
me = user.username
base_query = accessible_tickets_query(user)
# 我的工单:提交人 + 处理人 + 抄送
my_tickets = base_query.count()
total = base_query.count()
by_status = {}
for key, label in STATUS.items():
by_status[key] = {"label": label, "count": base_query.filter_by(status=key).count()}
by_priority = {}
for key, label in PRIORITY.items():
by_priority[key] = {"label": label, "count": base_query.filter_by(priority=key).count()}
by_category = {}
for key, label in CATEGORY.items():
by_category[key] = {"label": label, "count": base_query.filter_by(category=key).count()}
recent = base_query.order_by(Ticket.updated_at.desc()).limit(10).all()
return render_template(
"dashboard.html",
total=total,
my_tickets=my_tickets,
by_status=by_status,
by_priority=by_priority,
by_category=by_category,
recent=recent,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
status_label=status_label,
priority_label=priority_label,
category_label=category_label,
)
@app.route("/tickets")
@login_required
def ticket_list():
"""工单列表(支持筛选)"""
user = current_user()
q_status = request.args.get("status", "")
q_priority = request.args.get("priority", "")
q_category = request.args.get("category", "")
q_tag = request.args.get("tag", "")
q_keyword = request.args.get("q", "")
query = accessible_tickets_query(user)
if q_status:
query = query.filter_by(status=q_status)
if q_priority:
query = query.filter_by(priority=q_priority)
if q_category:
query = query.filter_by(category=q_category)
if q_tag:
query = query.filter(Ticket.tags.like(f"%{q_tag}%"))
if q_keyword:
kw = f"%{q_keyword}%"
query = query.filter(
db.or_(Ticket.title.like(kw), Ticket.description.like(kw)),
)
tickets = query.order_by(Ticket.updated_at.desc()).all()
return render_template(
"ticket_list.html",
tickets=tickets,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
status_label=status_label,
priority_label=priority_label,
category_label=category_label,
parse_tags=parse_tags,
q_status=q_status,
q_priority=q_priority,
q_category=q_category,
q_tag=q_tag,
q_keyword=q_keyword,
)
@app.route("/ticket/<int:tid>")
@login_required
def ticket_detail(tid):
t = db.get_or_404(Ticket, tid)
user = current_user()
if not can_access_ticket(user, t):
abort(403)
atts = Attachment.query.filter_by(ticket_id=tid).order_by(
Attachment.uploaded_at.desc()
).all()
return render_template(
"ticket_detail.html",
ticket=t,
attachments=atts,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
status_label=status_label,
priority_label=priority_label,
category_label=category_label,
parse_tags=parse_tags,
parse_cc=parse_cc,
datetime=datetime,
all_users=all_users,
)
@app.route("/ticket/new", methods=["GET", "POST"])
@login_required
def ticket_new():
return _ticket_form(None)
@app.route("/ticket/<int:tid>/edit", methods=["GET", "POST"])
@login_required
def ticket_edit(tid):
t = db.get_or_404(Ticket, tid)
user = current_user()
if not can_access_ticket(user, t):
abort(403)
return _ticket_form(t)
def _ticket_form(ticket):
me = current_user()
if request.method == "POST":
title = request.form.get("title", "").strip()
desc = request.form.get("description", "").strip()
status = request.form.get("status", "open")
priority = request.form.get("priority", "medium")
category = request.form.get("category", "other")
assignee = request.form.get("assignee", "").strip()
requester = request.form.get("requester", "").strip()
cc_str = request.form.get("cc_users", "").strip()
tags = request.form.get("tags", "").strip()
if not title:
return "标题不能为空", 400
if ticket:
ticket.title = title
ticket.description = desc
ticket.status = status
ticket.priority = priority
ticket.category = category
ticket.assignee = assignee or None
ticket.requester = requester or None
ticket.cc_users = cc_str
ticket.tags = tags
# 分配人变更时通知新处理人
if assignee and assignee != me.username:
u = User.query.filter_by(username=assignee).first()
if u:
create_notification(
u.id,
f"工单 #{ticket.id} 已分配给你",
title,
ticket_id=ticket.id,
)
else:
ticket = Ticket(
title=title,
description=desc,
status=status,
priority=priority,
category=category,
assignee=assignee or None,
requester=me.username,
cc_users=cc_str,
tags=tags,
)
db.session.add(ticket)
db.session.commit()
# 通知新增的抄送人
cc_list = parse_cc(cc_str)
for uname in cc_list:
u = User.query.filter_by(username=uname).first()
if u and u.username != me.username:
create_notification(
u.id,
f"工单 #{ticket.id} 被抄送给你",
title,
ticket_id=ticket.id,
)
return redirect(f"/ticket/{ticket.id}")
return render_template(
"ticket_form.html",
ticket=ticket,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
is_edit=bool(ticket),
all_users=all_users,
current_username=me.username if me else "",
parse_cc=parse_cc,
)
@app.route("/ticket/<int:tid>/comment", methods=["POST"])
@login_required
def add_comment(tid):
t = db.get_or_404(Ticket, tid)
me = current_user()
content = request.form.get("content", "").strip()
if not content:
return "评论内容不能为空", 400
c = Comment(ticket_id=tid, author=me.username, content=content)
db.session.add(c)
# 通知:工单处理人 + 提交人 + 抄送人(避免通知自己)
notify_targets = set()
if t.assignee and t.assignee != me.username:
notify_targets.add(t.assignee)
if t.requester and t.requester != me.username:
notify_targets.add(t.requester)
for uname in parse_cc(t.cc_users):
if uname != me.username:
notify_targets.add(uname)
for uname in notify_targets:
u = User.query.filter_by(username=uname).first()
if u:
create_notification(
u.id,
f"工单 #{tid} 有新评论",
f"{me.username}: {content[:50]}{'...' if len(content) > 50 else ''}",
ticket_id=tid,
)
db.session.commit()
return redirect(f"/ticket/{tid}")
@app.route("/ticket/<int:tid>/close", methods=["POST"])
@login_required
def close_ticket(tid):
t = db.get_or_404(Ticket, tid)
t.status = "closed"
db.session.commit()
return redirect(f"/ticket/{tid}")
@app.route("/ticket/<int:tid>/delete", methods=["POST"])
@login_required
def delete_ticket(tid):
t = db.get_or_404(Ticket, tid)
db.session.delete(t)
db.session.commit()
return redirect("/tickets")
@app.template_filter("render_desc")
def render_desc_html(html):
"""
渲染描述中的内联图片,处理 contenteditable 的 div 包装。
只允许 <img> 和 <br> 标签,其他 HTML 转义。
contenteditable 默认会用 <div> 包裹段落,需要去掉。
"""
if not html:
return ""
from markupsafe import Markup, escape
import re
# Step 1: 将 <div>...</div> 替换为内容 + <br>,模拟内容editable的行为
def replace_div(m):
content = m.group(1)
return content + "\n"
html = re.sub(r'<div[^>]*>(.*?)</div>', replace_div, html, flags=re.DOTALL)
# Step 2: 将 <p>...</p> 替换为内容 + <br>
html = re.sub(r'<p[^>]*>(.*?)</p>', replace_div, html, flags=re.DOTALL)
# Step 3: 将 <br> 和 <br/> 替换为 \n
html = re.sub(r'<br\s*/?>', '\n', html)
# Step 4: 转义所有文本,但保留 <img> 标签
parts = []
last = 0
for m in re.finditer(r'<img[^>]*>', html):
if m.start() > last:
parts.append(escape(html[last:m.start()]))
parts.append(Markup(m.group(0)))
last = m.end()
if last < len(html):
parts.append(escape(html[last:]))
result = "".join(parts)
# 替换 \n 为 <br> 用于显示
return Markup(result).replace("\n", "<br>")
@app.errorhandler(403)
def forbidden(e):
return render_template("error.html", code=403, message="无权限访问此工单"), 403
@app.errorhandler(404)
def not_found(e):
return render_template("error.html", code=404, message="页面不存在"), 404
# ── 全局模板上下文 ────────────────────────────────────
@app.context_processor
def inject_globals():
"""每个模板都能访问的用户信息 + 未读通知数"""
unread = 0
if "user_id" in session:
unread = Notification.query.filter_by(
user_id=session["user_id"], read=False,
).count()
return dict(current_user=current_user, unread_count=unread)
# ── 启动 ──────────────────────────────────────────────
def seed_admin():
"""确保存在 admin 用户(密码 admin"""
if not User.query.filter_by(username="admin").first():
admin = User(username="admin", role="admin")
admin.set_password("admin")
db.session.add(admin)
db.session.commit()
print("✅ 已创建默认管理员: admin / admin")
if __name__ == "__main__":
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
with app.app_context():
db.create_all()
seed_admin()
app.run(host="0.0.0.0", port=5000, debug=True)