feat: 用户行为信誉分系统
- User 新增 credit_score 字段(0-200,默认100) - 信誉分影响检测阈值系数:高分降低敏感度,低分提高敏感度 - 发布成功+1分,被拦截-2分;申诉通过+10分,驳回-5分 - 新增手动调整和批量重算信誉分接口 - admin-users 页面显示信誉分进度条,支持编辑调整 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ class User(db.Model):
|
|||||||
title = db.Column(db.String(64), default="")
|
title = db.Column(db.String(64), default="")
|
||||||
phone = db.Column(db.String(32), default="")
|
phone = db.Column(db.String(32), default="")
|
||||||
is_admin = db.Column(db.Boolean, default=False)
|
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)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=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,
|
"title": self.title,
|
||||||
"phone": self.phone,
|
"phone": self.phone,
|
||||||
"is_admin": self.is_admin,
|
"is_admin": self.is_admin,
|
||||||
|
"credit_score": self.credit_score,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,11 +309,17 @@ def process_appeal(post_id: int):
|
|||||||
row.prediction = "ham"
|
row.prediction = "ham"
|
||||||
row.manual_review_status = "approved_ham"
|
row.manual_review_status = "approved_ham"
|
||||||
_upsert_manual_sample(row.text, "ham", admin.id if admin else None)
|
_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:
|
else:
|
||||||
row.status = "blocked"
|
row.status = "blocked"
|
||||||
row.prediction = "spam"
|
row.prediction = "spam"
|
||||||
row.manual_review_status = "confirmed_spam"
|
row.manual_review_status = "confirmed_spam"
|
||||||
_upsert_manual_sample(row.text, "spam", admin.id if admin else None)
|
_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()
|
db.session.commit()
|
||||||
return ok(_serialize_post(row), "申诉处理完成")
|
return ok(_serialize_post(row), "申诉处理完成")
|
||||||
@@ -404,6 +410,12 @@ def update_user(user_id: int):
|
|||||||
user.phone = (payload.get("phone") or "").strip()
|
user.phone = (payload.get("phone") or "").strip()
|
||||||
if "is_admin" in payload:
|
if "is_admin" in payload:
|
||||||
user.is_admin = bool(payload.get("is_admin"))
|
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 payload.get("password"):
|
||||||
if len(payload["password"]) < 6:
|
if len(payload["password"]) < 6:
|
||||||
return fail("密码至少6位", 400)
|
return fail("密码至少6位", 400)
|
||||||
@@ -427,3 +439,73 @@ def delete_user(user_id: int):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok({}, "用户已删除")
|
return ok({}, "用户已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.put("/users/<int:user_id>/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}, "信誉分批量重算完成")
|
||||||
|
|
||||||
|
|||||||
@@ -77,12 +77,22 @@ def _resolve_recipient(payload: dict, visibility: str, current_user_id: int):
|
|||||||
return recipient, None
|
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()
|
clf = _ensure_ready()
|
||||||
result = clf.predict(text)
|
result = clf.predict(text)
|
||||||
threshold = float(_get_config().spam_threshold)
|
base_threshold = float(_get_config().spam_threshold)
|
||||||
blocked = float(result["spam_probability"]) >= threshold
|
|
||||||
return result, threshold, blocked
|
# 信誉分影响阈值系数: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")
|
@content_bp.post("/publish")
|
||||||
@@ -103,7 +113,7 @@ def publish_text():
|
|||||||
if err:
|
if err:
|
||||||
return fail(err, 400)
|
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(
|
post = ContentPost(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -134,6 +144,13 @@ def publish_text():
|
|||||||
|
|
||||||
db.session.add(post)
|
db.session.add(post)
|
||||||
db.session.add(detect_log)
|
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()
|
db.session.commit()
|
||||||
|
|
||||||
feedback = "发布成功" if not blocked else "疑似垃圾信息,系统已拦截,可提交申诉"
|
feedback = "发布成功" if not blocked else "疑似垃圾信息,系统已拦截,可提交申诉"
|
||||||
@@ -171,7 +188,7 @@ def edit_post(post_id: int):
|
|||||||
if err:
|
if err:
|
||||||
return fail(err, 400)
|
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.text = result["text"]
|
||||||
post.visibility = visibility
|
post.visibility = visibility
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ def _threshold() -> float:
|
|||||||
return float(row.spam_threshold) if row else 0.75
|
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")
|
@spam_bp.post("/predict")
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def predict_one():
|
def predict_one():
|
||||||
@@ -46,7 +55,7 @@ def predict_one():
|
|||||||
|
|
||||||
clf = _ensure_ready()
|
clf = _ensure_ready()
|
||||||
result = clf.predict(text)
|
result = clf.predict(text)
|
||||||
threshold = _threshold()
|
threshold = _adjusted_threshold(user.credit_score or 100)
|
||||||
blocked = float(result["spam_probability"]) >= threshold
|
blocked = float(result["spam_probability"]) >= threshold
|
||||||
|
|
||||||
row = SpamPredictionLog(
|
row = SpamPredictionLog(
|
||||||
@@ -82,7 +91,7 @@ def predict_batch():
|
|||||||
clf = _ensure_ready()
|
clf = _ensure_ready()
|
||||||
rows = []
|
rows = []
|
||||||
results = []
|
results = []
|
||||||
threshold = _threshold()
|
threshold = _adjusted_threshold(user.credit_score or 100)
|
||||||
|
|
||||||
for text in items:
|
for text in items:
|
||||||
content = (text or "").strip()
|
content = (text or "").strip()
|
||||||
|
|||||||
10
backend/sql/update_credit_score.sql
Normal file
10
backend/sql/update_credit_score.sql
Normal file
@@ -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);
|
||||||
@@ -460,6 +460,32 @@ button.btn::after {
|
|||||||
color: var(--sub);
|
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 {
|
.chip-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ Page({
|
|||||||
title: '',
|
title: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
password: ''
|
password: '',
|
||||||
|
credit_score: 100
|
||||||
},
|
},
|
||||||
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
|
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
|
||||||
},
|
},
|
||||||
@@ -60,7 +61,8 @@ Page({
|
|||||||
title: row.title || '',
|
title: row.title || '',
|
||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
is_admin: !!row.is_admin,
|
is_admin: !!row.is_admin,
|
||||||
password: ''
|
password: '',
|
||||||
|
credit_score: row.credit_score || 100
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
<view class="list-item" wx:for="{{users}}" wx:key="id">
|
<view class="list-item" wx:for="{{users}}" wx:key="id">
|
||||||
<view class="item-title">{{item.nickname}}({{item.username}})</view>
|
<view class="item-title">{{item.nickname}}({{item.username}})</view>
|
||||||
<view class="item-sub">{{item.company || '未填写公司'}} · {{item.title || '未填写岗位'}} · {{item.is_admin ? '管理员' : '普通用户'}}</view>
|
<view class="item-sub">{{item.company || '未填写公司'}} · {{item.title || '未填写岗位'}} · {{item.is_admin ? '管理员' : '普通用户'}}</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">信誉分</text>
|
||||||
|
<view class="credit-score-bar">
|
||||||
|
<view class="credit-fill" style="width: {{(item.credit_score || 100) / 2}}%;"></view>
|
||||||
|
<text class="credit-value">{{item.credit_score || 100}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view wx:if="{{editUserId === item.id}}">
|
<view wx:if="{{editUserId === item.id}}">
|
||||||
<input class="input" placeholder="昵称" value="{{editForm.nickname}}" data-field="nickname" bindinput="onEditInput" />
|
<input class="input" placeholder="昵称" value="{{editForm.nickname}}" data-field="nickname" bindinput="onEditInput" />
|
||||||
@@ -34,6 +41,7 @@
|
|||||||
<input class="input" placeholder="岗位" value="{{editForm.title}}" data-field="title" bindinput="onEditInput" />
|
<input class="input" placeholder="岗位" value="{{editForm.title}}" data-field="title" bindinput="onEditInput" />
|
||||||
<input class="input" placeholder="手机号" value="{{editForm.phone}}" data-field="phone" bindinput="onEditInput" />
|
<input class="input" placeholder="手机号" value="{{editForm.phone}}" data-field="phone" bindinput="onEditInput" />
|
||||||
<input class="input" placeholder="新密码(可选)" password value="{{editForm.password}}" data-field="password" bindinput="onEditInput" />
|
<input class="input" placeholder="新密码(可选)" password value="{{editForm.password}}" data-field="password" bindinput="onEditInput" />
|
||||||
|
<input class="input" placeholder="信誉分(0-200)" type="number" value="{{editForm.credit_score}}" data-field="credit_score" bindinput="onEditInput" />
|
||||||
|
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<text class="label">管理员权限</text>
|
<text class="label">管理员权限</text>
|
||||||
|
|||||||
Reference in New Issue
Block a user