diff --git a/backend/app/models.py b/backend/app/models.py index 4b453cc..5361142 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -16,6 +16,7 @@ class User(db.Model): title = db.Column(db.String(64), default="") phone = db.Column(db.String(32), default="") is_admin = db.Column(db.Boolean, default=False) + credit_score = db.Column(db.Integer, default=100) # 信誉分 0-200,默认100 created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -40,6 +41,7 @@ class User(db.Model): "title": self.title, "phone": self.phone, "is_admin": self.is_admin, + "credit_score": self.credit_score, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } diff --git a/backend/app/routes/admin_routes.py b/backend/app/routes/admin_routes.py index c1de981..8c0dc97 100644 --- a/backend/app/routes/admin_routes.py +++ b/backend/app/routes/admin_routes.py @@ -309,11 +309,17 @@ def process_appeal(post_id: int): row.prediction = "ham" row.manual_review_status = "approved_ham" _upsert_manual_sample(row.text, "ham", admin.id if admin else None) + # 申诉通过,增加用户信誉分 + if row.author: + row.author.credit_score = min(200, (row.author.credit_score or 100) + 10) else: row.status = "blocked" row.prediction = "spam" row.manual_review_status = "confirmed_spam" _upsert_manual_sample(row.text, "spam", admin.id if admin else None) + # 申诉驳回,减少用户信誉分 + if row.author: + row.author.credit_score = max(0, (row.author.credit_score or 100) - 5) db.session.commit() return ok(_serialize_post(row), "申诉处理完成") @@ -404,6 +410,12 @@ def update_user(user_id: int): user.phone = (payload.get("phone") or "").strip() if "is_admin" in payload: user.is_admin = bool(payload.get("is_admin")) + if "credit_score" in payload: + try: + credit = int(payload.get("credit_score", 100)) + user.credit_score = max(0, min(200, credit)) + except Exception: + pass if payload.get("password"): if len(payload["password"]) < 6: return fail("密码至少6位", 400) @@ -427,3 +439,73 @@ def delete_user(user_id: int): db.session.commit() return ok({}, "用户已删除") + +@admin_bp.put("/users//credit") +@admin_required +def update_user_credit(user_id: int): + """手动调整用户信誉分""" + user = User.query.get(user_id) + if not user: + return fail("用户不存在", 404) + + payload = request.get_json(silent=True) or {} + try: + credit = int(payload.get("credit_score", user.credit_score or 100)) + credit = max(0, min(200, credit)) + except Exception: + return fail("信誉分必须是0-200之间的整数", 400) + + user.credit_score = credit + db.session.commit() + return ok(user.to_dict(), "信誉分已更新") + + +@admin_bp.post("/users/recalculate-credit") +@admin_required +def recalculate_all_credit(): + """根据用户发布历史和申诉通过率重新计算信誉分""" + users = User.query.filter_by(is_admin=False).all() + updated_count = 0 + + for user in users: + posts = ContentPost.query.filter_by(user_id=user.id).all() + if not posts: + continue + + # 计算发布成功率 + published_count = sum(1 for p in posts if p.status == "published") + blocked_count = sum(1 for p in posts if p.status == "blocked") + total_count = len(posts) + + if total_count == 0: + continue + + publish_ratio = published_count / total_count + + # 计算申诉通过率 + appeals = [p for p in posts if p.appeal_status != "none"] + approved_appeals = sum(1 for p in appeals if p.appeal_status == "approved") + appeal_ratio = approved_appeals / len(appeals) if appeals else 0 + + # 基础信誉分:发布成功率贡献 + base_score = 100 + if publish_ratio >= 0.9: + base_score += 30 # 90%以上发布成功,+30 + elif publish_ratio >= 0.7: + base_score += 15 # 70%以上,+15 + elif publish_ratio < 0.5: + base_score -= 20 # 低于50%,-20 + + # 申诉通过率贡献 + if appeal_ratio >= 0.8 and len(appeals) >= 3: + base_score += 20 # 80%以上申诉通过且有3次以上申诉,+20 + elif appeal_ratio >= 0.5 and len(appeals) >= 2: + base_score += 10 + + # 限制范围 + user.credit_score = max(0, min(200, base_score)) + updated_count += 1 + + db.session.commit() + return ok({"updated_count": updated_count}, "信誉分批量重算完成") + diff --git a/backend/app/routes/content_routes.py b/backend/app/routes/content_routes.py index 0ceb525..36ee323 100644 --- a/backend/app/routes/content_routes.py +++ b/backend/app/routes/content_routes.py @@ -77,12 +77,22 @@ def _resolve_recipient(payload: dict, visibility: str, current_user_id: int): return recipient, None -def _predict_and_decide(text: str) -> tuple[dict, float, bool]: +def _predict_and_decide(text: str, user_credit: int = 100) -> tuple[dict, float, bool]: + """根据用户信誉分调整阈值系数。信誉分越高,阈值越高(降低敏感度)""" clf = _ensure_ready() result = clf.predict(text) - threshold = float(_get_config().spam_threshold) - blocked = float(result["spam_probability"]) >= threshold - return result, threshold, blocked + base_threshold = float(_get_config().spam_threshold) + + # 信誉分影响阈值系数:credit 0-200,默认100 + # credit > 100:阈值提高(降低敏感度,减少误判) + # credit < 100:阈值降低(提高敏感度,加强拦截) + # 系数范围:0.85 - 1.15 + credit_factor = 1.0 + (user_credit - 100) * 0.0015 # 每10分变化1.5% + credit_factor = max(0.85, min(1.15, credit_factor)) + + adjusted_threshold = base_threshold * credit_factor + blocked = float(result["spam_probability"]) >= adjusted_threshold + return result, adjusted_threshold, blocked @content_bp.post("/publish") @@ -103,7 +113,7 @@ def publish_text(): if err: return fail(err, 400) - result, threshold, blocked = _predict_and_decide(text) + result, threshold, blocked = _predict_and_decide(text, user.credit_score or 100) post = ContentPost( user_id=user.id, @@ -134,6 +144,13 @@ def publish_text(): db.session.add(post) db.session.add(detect_log) + + # 发布成功(未被拦截),小幅增加信誉分;被拦截则小幅减少 + if not blocked: + user.credit_score = min(200, (user.credit_score or 100) + 1) + else: + user.credit_score = max(0, (user.credit_score or 100) - 2) + db.session.commit() feedback = "发布成功" if not blocked else "疑似垃圾信息,系统已拦截,可提交申诉" @@ -171,7 +188,7 @@ def edit_post(post_id: int): if err: return fail(err, 400) - result, threshold, blocked = _predict_and_decide(text) + result, threshold, blocked = _predict_and_decide(text, user.credit_score or 100) post.text = result["text"] post.visibility = visibility diff --git a/backend/app/routes/spam_routes.py b/backend/app/routes/spam_routes.py index 6cddb6b..9c3c90b 100644 --- a/backend/app/routes/spam_routes.py +++ b/backend/app/routes/spam_routes.py @@ -32,6 +32,15 @@ def _threshold() -> float: return float(row.spam_threshold) if row else 0.75 +def _adjusted_threshold(user_credit: int = 100) -> float: + """根据用户信誉分调整阈值""" + base_threshold = _threshold() + # 系数范围:0.85 - 1.15 + credit_factor = 1.0 + (user_credit - 100) * 0.0015 + credit_factor = max(0.85, min(1.15, credit_factor)) + return base_threshold * credit_factor + + @spam_bp.post("/predict") @jwt_required() def predict_one(): @@ -46,7 +55,7 @@ def predict_one(): clf = _ensure_ready() result = clf.predict(text) - threshold = _threshold() + threshold = _adjusted_threshold(user.credit_score or 100) blocked = float(result["spam_probability"]) >= threshold row = SpamPredictionLog( @@ -82,7 +91,7 @@ def predict_batch(): clf = _ensure_ready() rows = [] results = [] - threshold = _threshold() + threshold = _adjusted_threshold(user.credit_score or 100) for text in items: content = (text or "").strip() diff --git a/backend/sql/update_credit_score.sql b/backend/sql/update_credit_score.sql new file mode 100644 index 0000000..d2d6253 --- /dev/null +++ b/backend/sql/update_credit_score.sql @@ -0,0 +1,10 @@ +-- 用户信誉分字段 +-- 执行方式:mysql -u root -p database_name < update_credit_score.sql + +-- 新增用户信誉分字段(范围 0-200,默认 100) +ALTER TABLE users +ADD COLUMN credit_score INT DEFAULT 100 COMMENT '用户信誉分(0-200,默认100)' +AFTER is_admin; + +-- 更新索引(可选,便于按信誉分排序查询) +-- ALTER TABLE users ADD INDEX idx_credit_score (credit_score); \ No newline at end of file diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss index f03214d..a9f2acd 100644 --- a/miniprogram/app.wxss +++ b/miniprogram/app.wxss @@ -460,6 +460,32 @@ button.btn::after { color: var(--sub); } +/* 信誉分进度条 */ +.credit-score-bar { + position: relative; + width: 180rpx; + height: 28rpx; + border-radius: 14rpx; + background: rgba(85, 123, 166, 0.25); + overflow: hidden; +} + +.credit-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--success), var(--accent)); +} + +.credit-value { + position: absolute; + right: 8rpx; + top: 50%; + transform: translateY(-50%); + font-size: 22rpx; + font-weight: 600; + color: #f2f7ff; +} + .chip-group { display: flex; flex-wrap: wrap; diff --git a/miniprogram/pages/admin-users/index.js b/miniprogram/pages/admin-users/index.js index c64a6c6..e67ce67 100644 --- a/miniprogram/pages/admin-users/index.js +++ b/miniprogram/pages/admin-users/index.js @@ -12,7 +12,8 @@ Page({ title: '', phone: '', is_admin: false, - password: '' + password: '', + credit_score: 100 }, importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]' }, @@ -60,7 +61,8 @@ Page({ title: row.title || '', phone: row.phone || '', is_admin: !!row.is_admin, - password: '' + password: '', + credit_score: row.credit_score || 100 } }) }, diff --git a/miniprogram/pages/admin-users/index.wxml b/miniprogram/pages/admin-users/index.wxml index cf55ccf..4881a92 100644 --- a/miniprogram/pages/admin-users/index.wxml +++ b/miniprogram/pages/admin-users/index.wxml @@ -27,6 +27,13 @@ {{item.nickname}}({{item.username}}) {{item.company || '未填写公司'}} · {{item.title || '未填写岗位'}} · {{item.is_admin ? '管理员' : '普通用户'}} + + 信誉分 + + + {{item.credit_score || 100}} + + @@ -34,6 +41,7 @@ + 管理员权限