解决超话签到中选择性签到的逻辑问题
This commit is contained in:
@@ -304,7 +304,34 @@ async def list_topics(
|
|||||||
return error_response("Cookie 解密失败", "COOKIE_ERROR", status_code=400)
|
return error_response("Cookie 解密失败", "COOKIE_ERROR", status_code=400)
|
||||||
|
|
||||||
topics = await _get_super_topics(cookie_str, account.weibo_user_id)
|
topics = await _get_super_topics(cookie_str, account.weibo_user_id)
|
||||||
return success_response({"topics": topics, "total": len(topics)})
|
return success_response({
|
||||||
|
"topics": topics,
|
||||||
|
"total": len(topics),
|
||||||
|
"selected_topics": account.selected_topics,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{account_id}/topics")
|
||||||
|
async def save_selected_topics(
|
||||||
|
account_id: str,
|
||||||
|
body: dict = Body(...),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""保存用户选择的签到超话列表。空列表或 null 表示签到全部。"""
|
||||||
|
account = await _get_owned_account(account_id, user, db)
|
||||||
|
selected = body.get("selected_topics")
|
||||||
|
# null 或空列表都表示全部签到
|
||||||
|
if selected and isinstance(selected, list) and len(selected) > 0:
|
||||||
|
account.selected_topics = selected
|
||||||
|
else:
|
||||||
|
account.selected_topics = None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(account)
|
||||||
|
return success_response(
|
||||||
|
_account_to_dict(account),
|
||||||
|
f"已保存 {len(selected) if selected else 0} 个超话" if selected else "已设为签到全部超话",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---- MANUAL SIGNIN ----
|
# ---- MANUAL SIGNIN ----
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class AccountResponse(BaseModel):
|
|||||||
weibo_user_id: str
|
weibo_user_id: str
|
||||||
remark: Optional[str]
|
remark: Optional[str]
|
||||||
status: str
|
status: str
|
||||||
|
selected_topics: Optional[list] = None
|
||||||
last_checked_at: Optional[datetime]
|
last_checked_at: Optional[datetime]
|
||||||
created_at: Optional[datetime]
|
created_at: Optional[datetime]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
|
from sqlalchemy import Column, DateTime, ForeignKey, JSON, String, Text
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ class Account(Base):
|
|||||||
encrypted_cookies = Column(Text, nullable=False)
|
encrypted_cookies = Column(Text, nullable=False)
|
||||||
iv = Column(String(32), nullable=False)
|
iv = Column(String(32), nullable=False)
|
||||||
status = Column(String(20), default="pending")
|
status = Column(String(20), default="pending")
|
||||||
|
selected_topics = Column(JSON, nullable=True) # 用户选择的签到超话列表,null=全部
|
||||||
last_checked_at = Column(DateTime, nullable=True)
|
last_checked_at = Column(DateTime, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ async def _async_do_signin(account_id: str, cron_expr: str = ""):
|
|||||||
return {"status": "failed", "reason": "cookie decryption failed"}
|
return {"status": "failed", "reason": "cookie decryption failed"}
|
||||||
|
|
||||||
acc_id = str(account.id)
|
acc_id = str(account.id)
|
||||||
|
acc_selected_topics = account.selected_topics # 用户选择的超话列表
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await eng.dispose()
|
await eng.dispose()
|
||||||
raise e
|
raise e
|
||||||
@@ -387,6 +388,16 @@ async def _async_do_signin(account_id: str, cron_expr: str = ""):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": "completed", "signed": 0, "message": "no topics"}
|
return {"status": "completed", "signed": 0, "message": "no topics"}
|
||||||
|
|
||||||
|
# 如果用户选择了特定超话,只签选中的
|
||||||
|
if acc_selected_topics and isinstance(acc_selected_topics, list):
|
||||||
|
selected_cids = {t.get("containerid") for t in acc_selected_topics if t.get("containerid")}
|
||||||
|
if selected_cids:
|
||||||
|
topics = [t for t in topics if t["containerid"] in selected_cids]
|
||||||
|
logger.info(f"📌 按用户选择过滤: {len(topics)} 个超话 (共 {len(selected_cids)} 个已选)")
|
||||||
|
|
||||||
|
if not topics:
|
||||||
|
return {"status": "completed", "signed": 0, "message": "no selected topics"}
|
||||||
|
|
||||||
signed = already = failed = 0
|
signed = already = failed = 0
|
||||||
log_entries = []
|
log_entries = []
|
||||||
|
|
||||||
|
|||||||
@@ -1085,7 +1085,9 @@ def account_topics(account_id):
|
|||||||
try:
|
try:
|
||||||
resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics')
|
resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics')
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
topics = data.get('data', {}).get('topics', []) if data.get('success') else []
|
payload = data.get('data', {}) if data.get('success') else {}
|
||||||
|
topics = payload.get('topics', [])
|
||||||
|
selected_topics = payload.get('selected_topics') or []
|
||||||
|
|
||||||
# 获取账号信息
|
# 获取账号信息
|
||||||
acc_resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}')
|
acc_resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}')
|
||||||
@@ -1096,12 +1098,32 @@ def account_topics(account_id):
|
|||||||
flash('账号不存在', 'danger')
|
flash('账号不存在', 'danger')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
return render_template('topics.html', account=account, topics=topics, user=session.get('user'))
|
return render_template('topics.html', account=account, topics=topics, selected_topics=selected_topics, user=session.get('user'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'获取超话列表失败: {str(e)}', 'danger')
|
flash(f'获取超话列表失败: {str(e)}', 'danger')
|
||||||
return redirect(url_for('account_detail', account_id=account_id))
|
return redirect(url_for('account_detail', account_id=account_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/accounts/<account_id>/topics/save', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def save_topics(account_id):
|
||||||
|
"""保存用户选择的签到超话"""
|
||||||
|
try:
|
||||||
|
body = request.json
|
||||||
|
resp = api_request(
|
||||||
|
'PUT',
|
||||||
|
f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics',
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('success'):
|
||||||
|
return jsonify({'success': True, 'message': data.get('message', '保存成功')})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'message': data.get('message', '保存失败')}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/accounts/<account_id>/signin-selected', methods=['POST'])
|
@app.route('/accounts/<account_id>/signin-selected', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def signin_selected(account_id):
|
def signin_selected(account_id):
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
.topics-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
.topics-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 10px; }
|
||||||
.topics-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; }
|
.topics-header h1 { font-size: 22px; font-weight: 700; color: #1e293b; }
|
||||||
.topic-list { display: flex; flex-direction: column; gap: 0; }
|
.topic-list { display: flex; flex-direction: column; gap: 0; }
|
||||||
.topic-item {
|
.topic-item {
|
||||||
display: flex; align-items: center; gap: 14px; padding: 14px 16px;
|
display: flex; align-items: center; gap: 14px; padding: 14px 16px;
|
||||||
@@ -19,22 +19,25 @@
|
|||||||
.select-bar {
|
.select-bar {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
padding: 16px 0; border-bottom: 1px solid #e2e8f0; margin-bottom: 8px;
|
padding: 16px 0; border-bottom: 1px solid #e2e8f0; margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap; gap: 8px;
|
||||||
}
|
}
|
||||||
.select-bar label { font-weight: 600; color: #475569; font-size: 14px; cursor: pointer; }
|
.select-bar label { font-weight: 600; color: #475569; font-size: 14px; cursor: pointer; }
|
||||||
.signin-btn {
|
.action-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
padding: 14px 32px; border-radius: 16px; border: none; font-size: 16px;
|
.signin-btn, .save-btn {
|
||||||
font-weight: 600; cursor: pointer; color: white;
|
padding: 10px 20px; border-radius: 12px; border: none; font-size: 14px;
|
||||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
font-weight: 600; cursor: pointer; color: white; transition: all 0.2s;
|
||||||
box-shadow: 0 2px 12px rgba(99,102,241,0.3); transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.signin-btn:hover { box-shadow: 0 4px 20px rgba(99,102,241,0.4); transform: translateY(-1px); }
|
|
||||||
.signin-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
|
|
||||||
.result-box {
|
|
||||||
margin-top: 20px; padding: 16px; border-radius: 14px; display: none;
|
|
||||||
font-size: 14px; font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
.signin-btn { background: linear-gradient(135deg, #6366f1, #818cf8); box-shadow: 0 2px 8px rgba(99,102,241,0.25); }
|
||||||
|
.save-btn { background: linear-gradient(135deg, #10b981, #059669); box-shadow: 0 2px 8px rgba(16,185,129,0.25); }
|
||||||
|
.signin-btn:disabled, .save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.result-box { margin-top: 16px; padding: 14px; border-radius: 12px; display: none; font-size: 13px; font-weight: 500; }
|
||||||
.result-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
|
.result-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
|
||||||
.result-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
.result-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||||
|
.tip-box { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; border-radius: 12px; padding: 12px 16px; margin-bottom: 16px; font-size: 13px; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.action-btns { width: 100%; }
|
||||||
|
.signin-btn, .save-btn { flex: 1; text-align: center; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -42,24 +45,35 @@
|
|||||||
<div style="max-width: 720px; margin: 0 auto;">
|
<div style="max-width: 720px; margin: 0 auto;">
|
||||||
<div class="topics-header">
|
<div class="topics-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>🔥 选择签到超话</h1>
|
<h1>🔥 超话签到管理</h1>
|
||||||
<div style="color:#94a3b8; font-size:14px; margin-top:4px;">
|
<div style="color:#94a3b8; font-size:13px; margin-top:4px;">
|
||||||
{{ account.remark or account.weibo_user_id }} · 共 {{ topics|length }} 个超话
|
{{ account.remark or account.weibo_user_id }} · 共 {{ topics|length }} 个超话
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary">← 返回</a>
|
<a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary">← 返回</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tip-box">
|
||||||
|
💡 勾选要参与定时签到的超话,点击「保存选择」后,定时任务和手动签到都只签选中的超话。不选则签到全部。
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{% if topics %}
|
{% if topics %}
|
||||||
<div class="select-bar">
|
<div class="select-bar">
|
||||||
<label><input type="checkbox" id="selectAll" class="topic-cb" checked onchange="toggleAll()"> 全选 (<span id="selectedCount">{{ topics|length }}</span>/{{ topics|length }})</label>
|
<label><input type="checkbox" id="selectAll" class="topic-cb" onchange="toggleAll()"> 全选 (<span id="selectedCount">0</span>/{{ topics|length }})</label>
|
||||||
<button class="signin-btn" id="signinBtn" onclick="doSignin()">🚀 签到选中超话</button>
|
<div class="action-btns">
|
||||||
|
<button class="save-btn" id="saveBtn" onclick="saveSelection()">💾 保存选择</button>
|
||||||
|
<button class="signin-btn" id="signinBtn" onclick="doSignin()">🚀 立即签到选中</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topic-list" id="topicList">
|
<div class="topic-list" id="topicList">
|
||||||
{% for topic in topics %}
|
{% for topic in topics %}
|
||||||
<label class="topic-item">
|
<label class="topic-item">
|
||||||
<input type="checkbox" class="topic-cb topic-check" data-index="{{ loop.index0 }}" checked onchange="updateCount()">
|
<input type="checkbox" class="topic-cb topic-check"
|
||||||
|
data-index="{{ loop.index0 }}"
|
||||||
|
data-title="{{ topic.title }}"
|
||||||
|
data-cid="{{ topic.containerid }}"
|
||||||
|
onchange="updateCount()">
|
||||||
<div>
|
<div>
|
||||||
<div class="topic-name">{{ topic.title }}</div>
|
<div class="topic-name">{{ topic.title }}</div>
|
||||||
<div class="topic-id">{{ topic.containerid }}</div>
|
<div class="topic-id">{{ topic.containerid }}</div>
|
||||||
@@ -78,6 +92,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// 已保存的选中超话
|
||||||
|
const savedTopics = {{ (selected_topics or [])|tojson }};
|
||||||
|
const savedCids = new Set(savedTopics.map(t => t.containerid));
|
||||||
|
|
||||||
|
// 页面加载时恢复选中状态
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const checks = document.querySelectorAll('.topic-check');
|
||||||
|
if (savedCids.size > 0) {
|
||||||
|
checks.forEach(cb => {
|
||||||
|
cb.checked = savedCids.has(cb.dataset.cid);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 没有保存过 = 全部选中
|
||||||
|
checks.forEach(cb => cb.checked = true);
|
||||||
|
}
|
||||||
|
updateCount();
|
||||||
|
});
|
||||||
|
|
||||||
function toggleAll() {
|
function toggleAll() {
|
||||||
const checked = document.getElementById('selectAll').checked;
|
const checked = document.getElementById('selectAll').checked;
|
||||||
document.querySelectorAll('.topic-check').forEach(cb => cb.checked = checked);
|
document.querySelectorAll('.topic-check').forEach(cb => cb.checked = checked);
|
||||||
@@ -91,6 +123,49 @@ function updateCount() {
|
|||||||
document.getElementById('selectAll').checked = (checked === total);
|
document.getElementById('selectAll').checked = (checked === total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectedTopics() {
|
||||||
|
const selected = [];
|
||||||
|
document.querySelectorAll('.topic-check:checked').forEach(cb => {
|
||||||
|
selected.push({ title: cb.dataset.title, containerid: cb.dataset.cid });
|
||||||
|
});
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelection() {
|
||||||
|
const btn = document.getElementById('saveBtn');
|
||||||
|
const resultBox = document.getElementById('resultBox');
|
||||||
|
const selected = getSelectedTopics();
|
||||||
|
const total = document.querySelectorAll('.topic-check').length;
|
||||||
|
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 保存中...';
|
||||||
|
try {
|
||||||
|
// 全选时传 null(签到全部)
|
||||||
|
const body = (selected.length === total)
|
||||||
|
? { selected_topics: null }
|
||||||
|
: { selected_topics: selected };
|
||||||
|
|
||||||
|
const resp = await fetch('/accounts/{{ account.id }}/topics/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
resultBox.style.display = 'block';
|
||||||
|
if (data.success) {
|
||||||
|
resultBox.className = 'result-box result-success';
|
||||||
|
resultBox.textContent = '✅ ' + data.message;
|
||||||
|
} else {
|
||||||
|
resultBox.className = 'result-box result-error';
|
||||||
|
resultBox.textContent = data.message || '保存失败';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
resultBox.className = 'result-box result-error';
|
||||||
|
resultBox.style.display = 'block';
|
||||||
|
resultBox.textContent = '请求失败: ' + e.message;
|
||||||
|
}
|
||||||
|
btn.disabled = false; btn.textContent = '💾 保存选择';
|
||||||
|
}
|
||||||
|
|
||||||
async function doSignin() {
|
async function doSignin() {
|
||||||
const btn = document.getElementById('signinBtn');
|
const btn = document.getElementById('signinBtn');
|
||||||
const resultBox = document.getElementById('resultBox');
|
const resultBox = document.getElementById('resultBox');
|
||||||
@@ -98,18 +173,14 @@ async function doSignin() {
|
|||||||
document.querySelectorAll('.topic-check:checked').forEach(cb => {
|
document.querySelectorAll('.topic-check:checked').forEach(cb => {
|
||||||
indices.push(parseInt(cb.dataset.index));
|
indices.push(parseInt(cb.dataset.index));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (indices.length === 0) {
|
if (indices.length === 0) {
|
||||||
resultBox.className = 'result-box result-error';
|
resultBox.className = 'result-box result-error';
|
||||||
resultBox.style.display = 'block';
|
resultBox.style.display = 'block';
|
||||||
resultBox.textContent = '请至少选择一个超话';
|
resultBox.textContent = '请至少选择一个超话';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 签到中...';
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '⏳ 签到中...';
|
|
||||||
resultBox.style.display = 'none';
|
resultBox.style.display = 'none';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('{{ url_for("signin_selected", account_id=account.id) }}', {
|
const resp = await fetch('{{ url_for("signin_selected", account_id=account.id) }}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -117,7 +188,6 @@ async function doSignin() {
|
|||||||
body: JSON.stringify({topic_indices: indices}),
|
body: JSON.stringify({topic_indices: indices}),
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
resultBox.style.display = 'block';
|
resultBox.style.display = 'block';
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
resultBox.className = 'result-box result-success';
|
resultBox.className = 'result-box result-success';
|
||||||
@@ -130,10 +200,8 @@ async function doSignin() {
|
|||||||
resultBox.className = 'result-box result-error';
|
resultBox.className = 'result-box result-error';
|
||||||
resultBox.style.display = 'block';
|
resultBox.style.display = 'block';
|
||||||
resultBox.textContent = '请求失败: ' + e.message;
|
resultBox.textContent = '请求失败: ' + e.message;
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '🚀 签到选中超话';
|
|
||||||
}
|
}
|
||||||
|
btn.disabled = false; btn.textContent = '🚀 立即签到选中';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
encrypted_cookies TEXT NOT NULL,
|
encrypted_cookies TEXT NOT NULL,
|
||||||
iv VARCHAR(32) NOT NULL,
|
iv VARCHAR(32) NOT NULL,
|
||||||
status VARCHAR(20) DEFAULT 'pending',
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
selected_topics JSON DEFAULT NULL,
|
||||||
last_checked_at TIMESTAMP NULL,
|
last_checked_at TIMESTAMP NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
INDEX idx_accounts_user_id (user_id),
|
INDEX idx_accounts_user_id (user_id),
|
||||||
|
|||||||
4
migrate_add_selected_topics.sql
Normal file
4
migrate_add_selected_topics.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- 给 accounts 表添加 selected_topics 字段
|
||||||
|
-- 用法: mysql -u weibo -p123456 weibo_hotsign < migrate_add_selected_topics.sql
|
||||||
|
|
||||||
|
ALTER TABLE accounts ADD COLUMN selected_topics JSON DEFAULT NULL AFTER status;
|
||||||
Reference in New Issue
Block a user