Files
aios/overlays/user-control-center/control/portal_views.py
T
2026-07-06 19:16:31 +08:00

1133 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
from pathlib import Path
import requests
from datetime import datetime, time, timedelta
from urllib.parse import urlencode
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from django.views.decorators.cache import never_cache
from django.db.models import Count, Q, Sum
from .models import AuditLog, ChannelBinding, GroupAdminApproval, GroupAdminOperation, InviteCode, InviteRedemption, QuotaPolicy, UsageCounter, UserGroup, UserProfile
from .services import (
build_effective_context,
create_wechat_qr_session,
get_qr_session_status,
invite_display_code_for_admin,
InviteValidationError,
merge_group_permission_policies,
normalize_invite_code,
profile_groups,
sync_user_to_router,
wechat_qr_can_finish,
write_audit,
)
from .tasks import poll_qr_session
def _is_placeholder_wechat_binding(binding):
chat_id = (binding.external_chat_id or '').strip()
session_id = (binding.qr_session_id or '').strip()
return (
chat_id.startswith('uat-')
or session_id.startswith('mock-wechat-')
or binding.status in {
ChannelBinding.STATUS_EXPIRED,
ChannelBinding.STATUS_DISABLED,
ChannelBinding.STATUS_UNBOUND,
ChannelBinding.STATUS_FAILED,
}
)
def _visible_channel_bindings(profile):
if not profile:
return []
visible = []
seen = set()
seen_wechat_chat_ids = set()
qs = profile.channel_bindings.filter(is_deleted=False).order_by('channel_type', '-is_primary', '-updated_at')
for binding in qs:
if _is_placeholder_wechat_binding(binding):
continue
if binding.channel_type == ChannelBinding.CHANNEL_WECHAT:
chat_id = (binding.external_chat_id or '').strip()
if chat_id:
if chat_id in seen_wechat_chat_ids:
continue
seen_wechat_chat_ids.add(chat_id)
visible.append(binding)
continue
key = binding.channel_type
if key in seen:
continue
seen.add(key)
visible.append(binding)
return visible
def _latest_wechat_binding(profile, statuses=None):
if not profile:
return None
qs = profile.channel_bindings.filter(
channel_type=ChannelBinding.CHANNEL_WECHAT,
is_deleted=False,
)
if statuses:
qs = qs.filter(status__in=statuses)
return qs.order_by('-updated_at').first()
def _active_wechat_pending(profile):
if not profile:
return None
return profile.channel_bindings.filter(
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_QR_PENDING,
is_deleted=False,
qr_expires_at__gt=timezone.now(),
).order_by('-updated_at').first()
def _dedupe_wechat_bound_bindings(bindings):
deduped = []
seen_chat_ids = set()
for binding in bindings:
chat_id = (binding.external_chat_id or '').strip()
if chat_id:
if chat_id in seen_chat_ids:
continue
seen_chat_ids.add(chat_id)
deduped.append(binding)
return deduped
def _promote_primary_channel(profile, channel_type, request=None):
if not profile:
return None
qs = profile.channel_bindings.filter(
channel_type=channel_type,
status=ChannelBinding.STATUS_BOUND,
is_deleted=False,
).order_by('-updated_at', '-id')
primary = qs.filter(is_primary=True).first()
if primary:
qs.filter(is_primary=True).exclude(pk=primary.pk).update(is_primary=False, updated_at=timezone.now())
return primary
candidate = qs.first()
if not candidate:
return None
before = {'is_primary': candidate.is_primary}
candidate.is_primary = True
candidate.save(update_fields=['is_primary', 'updated_at'])
write_audit(
request=request,
action='channel.primary.auto_promote',
target_type='ChannelBinding',
target_id=candidate.id,
before=before,
after={'is_primary': True},
)
return candidate
def _wechat_dashboard_context(profile):
if not profile:
return {}
bound_bindings = _dedupe_wechat_bound_bindings(list(profile.channel_bindings.filter(
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_BOUND,
is_deleted=False,
).order_by('-is_primary', '-updated_at', '-id')))
bound = bound_bindings[0] if bound_bindings else None
pending = _active_wechat_pending(profile)
latest = _latest_wechat_binding(profile)
binding = pending or bound or latest
if pending:
state = 'pending'
state_label = '等待扫码'
state_detail = '二维码有效期内,请用本人微信扫码完成连接。'
elif bound:
state = 'bound'
state_label = '已绑定'
state_detail = '个人微信已连接,可用于接收系统通知和任务消息。'
else:
state = 'unbound'
state_label = '未绑定'
state_detail = '生成真实微信绑定二维码后扫码绑定。'
groups = profile_groups(profile)
effective = build_effective_context(profile)
permissions = effective.get('permission_policy') or {}
plugin_qr_available = _real_wechat_qr_available()
return {
'wechat_binding': binding,
'wechat_bound_binding': bound,
'wechat_bound_bindings': bound_bindings,
'wechat_bound_count': len(bound_bindings),
'wechat_pending_binding': pending,
'wechat_state': state,
'wechat_state_label': state_label,
'wechat_state_detail': state_detail,
'wechat_remaining_seconds': _remaining_seconds(pending),
'wechat_plugin_qr_available': plugin_qr_available,
'wechat_can_generate': plugin_qr_available and not pending,
'profile_groups': groups,
'profile_capability_groups': effective.get('capability_groups', []),
'profile_permissions': permissions,
'profile_quota': permissions.get('quotas', {}),
}
def _is_owner_user(user):
return bool(
user
and getattr(user, 'is_authenticated', False)
and (
getattr(user, 'is_superuser', False)
or user.groups.filter(name='owner').exists()
)
)
def _admin_groups_for_user(user):
if not user or not getattr(user, 'is_authenticated', False):
return []
qs = UserGroup.objects.filter(group_type=UserGroup.TYPE_MANAGEMENT, is_active=True)
if _is_owner_user(user):
return list(qs.order_by('name', 'code'))
return list(qs.filter(admin_users=user).order_by('name', 'code').distinct())
def _select_admin_group(groups, raw_value=''):
if not groups:
return None
raw_value = str(raw_value or '').strip()
if raw_value:
for group in groups:
if raw_value in {str(group.id), group.code}:
return group
return groups[0]
def _mask_external_id(value):
value = str(value or '').strip()
if not value:
return ''
if len(value) <= 8:
return value[:2] + '****'
return value[:4] + '****' + value[-4:]
def _channel_summary_for_profile(member):
bindings = member.channel_bindings.filter(is_deleted=False).order_by('channel_type', '-updated_at')
labels = []
seen = set()
for binding in bindings:
if binding.channel_type in seen:
continue
seen.add(binding.channel_type)
if binding.status == ChannelBinding.STATUS_BOUND:
masked = _mask_external_id(binding.external_chat_id)
labels.append(f'{binding.channel_type}: 已绑定 {masked}' if masked else f'{binding.channel_type}: 已绑定')
elif binding.status == ChannelBinding.STATUS_QR_PENDING:
labels.append(f'{binding.channel_type}: 待扫码')
return ';'.join(labels) or '未绑定'
def _group_usage_total(group, member=None):
qs = UsageCounter.objects.filter(management_group=group)
if member is not None:
qs = qs.filter(user_profile=member)
return qs.aggregate(total=Sum('token_total')).get('total') or 0
def _usage_trend_days(profile, days=7):
if not profile:
return []
today = timezone.localdate()
start_day = today - timedelta(days=days - 1)
start_dt = timezone.make_aware(datetime.combine(start_day, time.min))
counters = UsageCounter.objects.filter(
user_profile=profile,
period=UsageCounter.PERIOD_DAY,
period_start__gte=start_dt,
)
totals_by_day = {}
for counter in counters:
day = timezone.localtime(counter.period_start).date()
totals_by_day[day] = totals_by_day.get(day, 0) + int(counter.token_total or 0)
cumulative = 0
rows = []
for offset in range(days):
day = start_day + timedelta(days=offset)
total = totals_by_day.get(day, 0)
cumulative += total
rows.append({
'iso': day.isoformat(),
'label': day.strftime('%m-%d'),
'token_total': total,
'cumulative_total': cumulative,
})
return rows
def _gitops_dashboard_context():
data = {
'active_version': 'unknown',
'active_tag': 'unknown',
'mode': 'gitops-online',
'updated_at': '',
'git': '',
'links': {},
'status': 'unavailable',
}
def apply_payload(payload, source):
data.update({
'active_version': payload.get('active_version') or payload.get('version') or 'unknown',
'active_tag': payload.get('active_tag') or payload.get('baseline_tag') or 'unknown',
'mode': payload.get('mode') or 'gitops-online',
'updated_at': payload.get('updated_at') or '',
'git': payload.get('git') or payload.get('commit') or '',
'links': payload.get('links') or {},
'status': 'ok',
'source': source,
'online_git_sync': (payload.get('codex_gitops') or {}).get('online_git_sync'),
})
errors = []
cache_candidates = [
os.environ.get('AIOS_GITOPS_BASELINE_FILE'),
getattr(settings, 'AIOS_GITOPS_BASELINE_FILE', ''),
'/app/runtime/gitops/current.json',
'/data/aios-gitops/workspace/baseline/current.json',
]
for candidate in cache_candidates:
if not candidate:
continue
try:
path = Path(candidate)
if path.is_file():
with path.open('r', encoding='utf-8') as handle:
apply_payload(json.load(handle), f'file:{path}')
break
except Exception as exc:
errors.append(f'{candidate}: {exc}'[:160])
if data['status'] != 'ok':
api_candidates = [
os.environ.get('AIOS_GITOPS_BASE_URL'),
'http://172.17.0.1:18890/api/base',
'http://host.docker.internal:18890/api/base',
'https://cnnetmkt.top/api/base',
]
for url in api_candidates:
if not url:
continue
try:
resp = requests.get(url, timeout=3)
if resp.status_code < 400:
apply_payload(resp.json(), url)
break
errors.append(f'{url}: HTTP {resp.status_code}')
except Exception as exc:
errors.append(f'{url}: {exc}'[:160])
if data['status'] != 'ok' and errors:
data['error'] = '; '.join(errors)[:160]
data.update({
'git_url': 'https://g.cnnetmkt.top/git/aios/aios-v23',
'schema_url': 'https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml',
'api_base': 'https://cnnetmkt.top/api',
'custom_gpt_url': 'https://chatgpt.com/g/g-6a4b7c82dff08191bc980798edea1bac-aios-gitops-kong-zhi-tai',
'human_shortlink_policy': 'login_required',
})
return data
def _group_admin_context(request, profile):
admin_groups = _admin_groups_for_user(request.user)
selected_group = _select_admin_group(admin_groups, request.GET.get('admin_group'))
if not selected_group:
return {
'is_group_admin': False,
'admin_groups': [],
'selected_admin_group': None,
'group_members': [],
'group_invites': [],
'group_audits': [],
}
members = list(UserProfile.objects.filter(
Q(management_groups=selected_group) | Q(active_group=selected_group) | Q(initial_group=selected_group),
is_deleted=False,
).select_related('auth_user', 'active_group', 'initial_group', 'primary_capability_group').distinct().order_by('display_name', 'user_id'))
admin_user_ids = set(selected_group.admin_users.values_list('id', flat=True))
member_rows = []
for member in members:
usage_total = _group_usage_total(selected_group, member)
is_member_admin = member.auth_user_id in admin_user_ids or _is_owner_user(member.auth_user)
last_active = member.last_active_at or member.updated_at
member_rows.append({
'profile': member,
'role': 'admin' if is_member_admin else 'user',
'role_label': '管理员' if is_member_admin else '用户',
'status_label': member.get_status_display(),
'channel_summary': _channel_summary_for_profile(member),
'token_total': usage_total,
'agent_count': 0,
'session_count': 0,
'last_active_at': last_active,
'last_active_label': timezone.localtime(last_active).strftime('%Y-%m-%d %H:%M') if last_active else '-',
})
invites = list(InviteCode.objects.filter(default_management_group=selected_group, is_deleted=False).select_related('issuer', 'admin_owner').order_by('-created_at')[:20])
redemption_counts = {
item['invite_code_id']: item['total']
for item in InviteRedemption.objects.filter(invite_code__in=invites)
.values('invite_code_id')
.annotate(total=Count('id'))
}
invite_rows = []
for invite in invites:
is_expired = bool(invite.expires_at and invite.expires_at <= timezone.now())
is_invalid = invite.status != InviteCode.STATUS_ACTIVE or is_expired
invite_rows.append({
'invite': invite,
'full_code': invite_display_code_for_admin(invite),
'masked_code': (invite.code_prefix or 'invite') + '****',
'created_label': timezone.localtime(invite.created_at).strftime('%Y-%m-%d %H:%M') if invite.created_at else '-',
'expires_label': timezone.localtime(invite.expires_at).strftime('%Y-%m-%d %H:%M') if invite.expires_at else '-',
'status_label': '已失效' if is_invalid else '可用',
'is_invalid': is_invalid,
'is_available': invite.is_available,
'remaining_uses': max(0, invite.max_uses - invite.used_count),
'redemption_count': redemption_counts.get(invite.id, 0),
'role_label': '管理员' if invite.default_role == 'group_admin' else '用户',
})
audits = list(AuditLog.objects.filter(
Q(target_type='UserGroup', target_id=str(selected_group.id))
| Q(target_type='InviteCode')
| Q(action__icontains='channel')
).order_by('-created_at')[:20])
operations = list(GroupAdminOperation.objects.filter(target_group=selected_group).select_related('target_user', 'operator_user').order_by('-created_at')[:20])
approvals = list(GroupAdminApproval.objects.filter(target_group=selected_group).select_related('target_user', 'operator_user').order_by('-created_at')[:20])
total_usage = _group_usage_total(selected_group)
active_members = sum(1 for row in member_rows if row['profile'].status == UserProfile.STATUS_ACTIVE)
wechat_bound = ChannelBinding.objects.filter(
user_profile__in=[row['profile'] for row in member_rows],
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_BOUND,
is_deleted=False,
).count()
return {
'is_group_admin': True,
'admin_groups': admin_groups,
'selected_admin_group': selected_group,
'group_members': member_rows,
'group_invites': invite_rows,
'group_audits': audits,
'group_operations': operations,
'group_approvals': approvals,
'group_member_count': len(member_rows),
'group_active_member_count': active_members,
'group_wechat_bound_count': wechat_bound,
'group_token_total': total_usage,
'group_invite_count': len(invite_rows),
'group_policy': selected_group.permission_policy or {},
'group_permissions': merge_group_permission_policies([selected_group]),
'available_capability_groups': list(UserGroup.objects.filter(group_type=UserGroup.TYPE_CAPABILITY, is_active=True, is_deleted=False).order_by('name', 'code')),
'available_quota_policies': list(QuotaPolicy.objects.filter(is_active=True).order_by('tier', 'name')),
'available_invite_target_groups': admin_groups,
'allowed_invite_roles': ['user', 'group_admin'] if _is_owner_user(request.user) else ['user'],
}
def _remaining_seconds(binding):
if not binding or not binding.qr_expires_at:
return 0
return max(0, int((binding.qr_expires_at - timezone.now()).total_seconds()))
def _is_ajax_request(request):
return (
request.headers.get('x-requested-with') == 'XMLHttpRequest'
or 'application/json' in request.headers.get('accept', '')
)
def _wechat_qr_ajax_payload(binding):
return {
'status': binding.status,
'binding_id': binding.id,
'qr_session_id': binding.qr_session_id,
'redirect_url': '/portal/bind/wechat/',
'status_url': f'/portal/bind/wechat/{binding.id}/status.json',
'qr_image_url': f'/portal/bind/wechat/{binding.id}/qr.png',
'remaining_seconds': _remaining_seconds(binding),
'marker': 'AIOS_R13_WECHAT_BIND_CLICK_FEEDBACK_OK',
}
def _apply_wechat_qr_status(binding, data, request=None):
status = str(data.get('status') or '').strip()
can_finish = wechat_qr_can_finish(binding)
if not can_finish:
return binding.status
if binding.status == ChannelBinding.STATUS_EXPIRED and status not in {'bound', 'scanned_confirmed'}:
return binding.status
if status in {'expired', 'failed', 'proof_required'}:
binding.status = ChannelBinding.STATUS_EXPIRED if status == 'expired' else ChannelBinding.STATUS_FAILED
binding.error_message = str(data.get('error_message') or data.get('reason') or status)[:255]
binding.save(update_fields=['status', 'error_message', 'updated_at'])
write_audit(request=request, action='channel.qr.status_sync', target_type='ChannelBinding', target_id=binding.id, after=data, result='blocked' if binding.status == ChannelBinding.STATUS_FAILED else 'success', error_message=binding.error_message)
return binding.status
if status not in {'bound', 'scanned_confirmed'}:
return binding.status
external_chat_id = data.get('external_chat_id', '')
proof_kind = str(data.get('proof_kind') or '').strip()
proof_verified = data.get('proof_verified') is True
unverified_binding = (
data.get('adapter_mode') in {'autobind', 'known_identity_autobind'}
or bool(data.get('known_identity_source'))
or external_chat_id.startswith('uat-')
or not proof_verified
or proof_kind not in {'openclaw_inbound_confirm', 'openclaw_plugin_callback', 'wechat_plugin_callback'}
)
if unverified_binding:
binding.status = ChannelBinding.STATUS_FAILED
binding.error_message = 'openclaw_binding_proof_required'
binding.save(update_fields=['status', 'error_message', 'updated_at'])
write_audit(request=request, action='channel.bind.rejected_unverified', target_type='ChannelBinding', target_id=binding.id, after=data, result='blocked', error_message='openclaw_binding_proof_required')
return binding.status
resolved_binding = binding.mark_bound(data.get('external_channel_id', ''), external_chat_id)
audit_after = dict(data)
if resolved_binding and resolved_binding.pk != binding.pk:
audit_after['deduped_to_binding_id'] = resolved_binding.id
audit_after['dedupe_reason'] = binding.error_message
if resolved_binding and resolved_binding.status == ChannelBinding.STATUS_BOUND:
write_audit(request=request, action='channel.bind.success', target_type='ChannelBinding', target_id=binding.id, after=audit_after)
sync_user_to_router(resolved_binding.user_profile)
else:
write_audit(
request=request,
action='channel.bind.rejected_duplicate',
target_type='ChannelBinding',
target_id=binding.id,
after=audit_after,
result='blocked',
error_message=getattr(resolved_binding, 'error_message', '') or binding.error_message,
)
return resolved_binding.status if resolved_binding else binding.status
def _revoke_wechat_adapter_sessions(profile, external_chat_ids, request=None, qr_session_ids=None):
if settings.CHANNEL_GATEWAY_MODE != 'real':
return {'status': 'skipped', 'reason': 'channel_gateway_not_real'}
chat_ids = [chat for chat in dict.fromkeys(str(x or '').strip() for x in external_chat_ids) if chat]
session_ids = [sid for sid in dict.fromkeys(str(x or '').strip().upper() for x in (qr_session_ids or [])) if sid]
if not profile and not chat_ids and not session_ids:
return {'status': 'skipped', 'reason': 'missing_profile_chat_and_session'}
payload = {
'user_id': getattr(profile, 'user_id', '') or '',
'external_chat_ids': chat_ids,
'qr_session_ids': session_ids,
'reason': 'cancelled_by_user',
}
resp = requests.post(
f'{settings.CHANNEL_GATEWAY_BASE_URL}/internal/channels/wechat/revoke-user',
headers={'Authorization': f'Bearer {settings.CHANNEL_GATEWAY_TOKEN}'},
json=payload,
timeout=10,
)
resp.raise_for_status()
data = resp.json()
write_audit(request=request, action='channel.wechat.adapter_revoke', target_type='UserProfile', target_id=getattr(profile, 'id', ''), after=data)
return data
def _clear_wechat_channel_memory(binding, request=None):
if settings.CHANNEL_GATEWAY_MODE != 'real':
return {'status': 'skipped', 'reason': 'channel_gateway_not_real'}
chat_id = str(binding.external_chat_id or '').strip()
if not chat_id:
return {'status': 'skipped', 'reason': 'missing_external_chat_id'}
payload = {
'user_id': binding.user_profile.user_id,
'binding_id': binding.id,
'external_chat_id': chat_id,
'qr_session_id': binding.qr_session_id,
'reason': 'cleared_by_user',
}
resp = requests.post(
f'{settings.CHANNEL_GATEWAY_BASE_URL}/internal/channels/wechat/clear-channel-memory',
headers={'Authorization': f'Bearer {settings.CHANNEL_GATEWAY_TOKEN}'},
json=payload,
timeout=15,
)
resp.raise_for_status()
return resp.json()
def _wechat_binding_status_payload(binding, adapter_status=None):
remaining = _remaining_seconds(binding) if binding.status == ChannelBinding.STATUS_QR_PENDING else 0
adapter_status = adapter_status or {}
countdown_text = ''
confirming_after_expiry = (
binding.status in {ChannelBinding.STATUS_QR_PENDING, ChannelBinding.STATUS_EXPIRED}
and binding.qr_expires_at
and binding.qr_expires_at <= timezone.now()
and wechat_qr_can_finish(binding)
)
display_status = ChannelBinding.STATUS_QR_PENDING if confirming_after_expiry else binding.status
message = '等待扫码确认。'
if confirming_after_expiry:
message = '扫码结果正在确认,请稍候。'
elif binding.status == ChannelBinding.STATUS_BOUND:
message = '绑定已完成。'
elif binding.status == ChannelBinding.STATUS_EXPIRED:
message = '二维码已过期,请重新生成。'
elif binding.status == ChannelBinding.STATUS_FAILED:
reason = str(binding.error_message or adapter_status.get('error_message') or '').strip()
if reason in {'openclaw_fresh_wechat_identity_required', 'openclaw_binding_proof_required'}:
message = '微信插件打开了聊天,但没有返回扫码微信身份,不能自动绑定。请重新生成二维码;如果仍然出现,请等待管理员接入带身份回调。'
countdown_text = '未完成绑定'
elif reason == 'chat_already_bound_to_other_user':
message = '该微信已经绑定到其他账号,不能重复绑定。请先在原账号解除绑定,或切换到对应账号继续使用。'
countdown_text = '已绑定到其他账号'
else:
message = '绑定失败,请重新生成。'
countdown_text = '绑定失败'
elif str(adapter_status.get('plugin_status') or adapter_status.get('status') or '') == 'scaned':
message = '已扫码,正在确认绑定。'
return {
'status': display_status,
'message': message,
'error_message': binding.error_message or adapter_status.get('error_message', ''),
'remaining_seconds': remaining,
'confirming_after_expiry': confirming_after_expiry,
'countdown_text': '正在确认绑定' if confirming_after_expiry else countdown_text,
'qr_session_id': binding.qr_session_id,
'adapter_status': adapter_status.get('status', ''),
'plugin_status': adapter_status.get('plugin_status', ''),
'external_chat_id': binding.external_chat_id if binding.status == ChannelBinding.STATUS_BOUND else '',
'account_url': '/portal/me/',
}
def _wechat_duplicate_bound_status_payload(binding, adapter_status=None):
adapter_status = adapter_status or {}
if binding.status != ChannelBinding.STATUS_UNBOUND:
return None
if (binding.error_message or '').strip() != 'duplicate_existing_chat_id':
return None
if str(adapter_status.get('status') or '') not in {'bound', 'scanned_confirmed'}:
return None
external_chat_id = str(adapter_status.get('external_chat_id') or '').strip()
if not external_chat_id:
return None
existing = (
ChannelBinding.objects.filter(
user_profile=binding.user_profile,
channel_type=ChannelBinding.CHANNEL_WECHAT,
external_chat_id=external_chat_id,
status=ChannelBinding.STATUS_BOUND,
is_deleted=False,
)
.exclude(pk=binding.pk)
.order_by('-is_primary', '-updated_at', '-id')
.first()
)
if not existing:
return None
payload = _wechat_binding_status_payload(existing, adapter_status)
payload.update({
'qr_session_id': binding.qr_session_id,
'deduped_to_binding_id': existing.id,
'duplicate_existing_chat_id': True,
'message': '绑定已完成。',
})
return payload
def _real_wechat_qr_available():
if settings.CHANNEL_GATEWAY_MODE != 'real':
return False
try:
resp = requests.get(
f'{settings.CHANNEL_GATEWAY_BASE_URL}/health',
headers={'Authorization': f'Bearer {settings.CHANNEL_GATEWAY_TOKEN}'},
timeout=5,
)
resp.raise_for_status()
data = resp.json()
except Exception:
return False
return data.get('plugin_qr_available') is True and data.get('qr_provider') == 'openclaw-weixin'
def _casdoor_oauth_url(path, request=None, extra_query=None):
base = (settings.CASDOOR_PUBLIC_BASE_URL or '').rstrip('/')
redirect_uri = settings.REDIRECT_URI
if request is not None:
redirect_uri = request.build_absolute_uri('/casdoor/callback/')
query_data = {
'client_id': settings.CASDOOR_CLIENT_ID,
'response_type': 'code',
'redirect_uri': redirect_uri,
'scope': 'read',
'state': settings.CASDOOR_APPLICATION,
'language': 'zh',
}
if extra_query:
query_data.update(extra_query)
query = urlencode(query_data)
return f'{base}{path}?{query}'
@never_cache
def portal_entry(request):
return redirect('/portal/login/')
@require_http_methods(['GET', 'POST'])
def register(request):
raw_code = request.POST.get('invite_code') or request.POST.get('code') or request.GET.get('invite_code') or request.GET.get('code') or ''
extra_query = {
'language': 'zh',
'locale': 'zh_CN',
'country': 'CN',
'countryCode': 'CN',
'phonePrefix': '+86',
}
if raw_code:
try:
invite_code = normalize_invite_code(raw_code)
except InviteValidationError:
invite_code = ''
if invite_code:
if hasattr(request, 'session'):
request.session['casdoor_signup_invite_code'] = invite_code
extra_query['invitationCode'] = invite_code
return redirect(_casdoor_oauth_url('/signup/oauth/authorize', request=request, extra_query=extra_query))
def identity_login(request):
return redirect(_casdoor_oauth_url('/login/oauth/authorize', request=request))
def identity_logout(request):
return redirect(settings.CASDOOR_PUBLIC_BASE_URL or '/admin/')
@login_required
@never_cache
def me(request):
profile = getattr(request.user, 'user_profile', None)
bindings = _visible_channel_bindings(profile)
context = {
'profile': profile,
'bindings': bindings,
'is_owner': _is_owner_user(request.user),
}
context.update(_wechat_dashboard_context(profile))
context.update(_group_admin_context(request, profile))
context['usage_trend_days'] = _usage_trend_days(profile)
context['gitops'] = _gitops_dashboard_context()
return render(request, 'portal/me.html', context)
@never_cache
def wechat_qr_scan_landing(request):
session_id = str(request.GET.get('session_id') or '').strip().upper()
binding = None
scan_error = ''
if session_id:
binding = ChannelBinding.objects.select_related('user_profile', 'user_profile__auth_user').filter(qr_session_id=session_id, channel_type=ChannelBinding.CHANNEL_WECHAT).first()
if binding and binding.status == ChannelBinding.STATUS_QR_PENDING and settings.CHANNEL_GATEWAY_MODE == 'real':
try:
resp = requests.post(
f'{settings.CHANNEL_GATEWAY_BASE_URL}/internal/channels/wechat/qr-session/{session_id}/scan-confirm',
headers={'Authorization': f'Bearer {settings.CHANNEL_GATEWAY_TOKEN}'},
timeout=15,
)
if resp.status_code < 400:
binding.refresh_from_db()
write_audit(request=request, action='channel.qr.scan_confirm.self', target_type='ChannelBinding', target_id=binding.id)
else:
scan_error = resp.text[:300]
except Exception as exc:
scan_error = str(exc)[:300]
can_unbind = bool(
binding
and binding.status == ChannelBinding.STATUS_BOUND
and request.user.is_authenticated
and getattr(binding.user_profile, 'auth_user_id', None) == request.user.id
)
return render(request, 'portal/wechat_qr_scan_landing.html', {
'binding': binding,
'binding_found': bool(binding),
'binding_bound': bool(binding and binding.status == ChannelBinding.STATUS_BOUND),
'can_unbind': can_unbind,
'status': binding.status if binding else '',
'expires_at': binding.qr_expires_at if binding else None,
'scan_error': scan_error,
})
@login_required
@never_cache
@require_http_methods(['GET', 'POST'])
def bind_wechat(request):
profile = getattr(request.user, 'user_profile', None)
if not profile:
messages.error(request, '当前账号没有关联用户资料。')
return redirect('/portal/me/')
bound_bindings = _dedupe_wechat_bound_bindings(list(profile.channel_bindings.filter(
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_BOUND,
is_deleted=False,
).order_by('-is_primary', '-updated_at', '-id')))
existing_bound = bound_bindings[0] if bound_bindings else None
plugin_qr_available = _real_wechat_qr_available()
existing_pending = profile.channel_bindings.filter(
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_QR_PENDING,
is_deleted=False,
qr_expires_at__gt=timezone.now(),
).order_by('-updated_at').first()
if request.method != 'POST':
if existing_pending:
return render(request, 'portal/bind_wechat.html', {
'binding': existing_pending,
'bindings': bound_bindings,
'channel_gateway_mode': settings.CHANNEL_GATEWAY_MODE if plugin_qr_available else 'unavailable',
'plugin_qr_available': plugin_qr_available,
'remaining_seconds': _remaining_seconds(existing_pending),
'can_generate': False,
})
if existing_bound:
return render(request, 'portal/bind_wechat.html', {
'binding': None,
'bindings': bound_bindings,
'channel_gateway_mode': settings.CHANNEL_GATEWAY_MODE,
'already_bound': True,
'plugin_qr_available': plugin_qr_available,
'can_generate': plugin_qr_available,
})
return render(request, 'portal/bind_wechat.html', {
'binding': None,
'bindings': bound_bindings,
'channel_gateway_mode': settings.CHANNEL_GATEWAY_MODE if plugin_qr_available else 'unavailable',
'can_generate': plugin_qr_available,
'plugin_qr_available': plugin_qr_available,
})
if not plugin_qr_available:
write_audit(request=request, action='channel.qr.unavailable.self', target_type='UserProfile', target_id=profile.id, result='blocked', error_message='openclaw_wechat_plugin_qr_unavailable')
if _is_ajax_request(request):
return JsonResponse({
'error': 'openclaw_wechat_plugin_qr_unavailable',
'message': '当前微信服务未提供可用的绑定二维码,暂不生成或展示二维码。',
'marker': 'AIOS_R13_WECHAT_BIND_CLICK_FEEDBACK_OK',
}, status=503, json_dumps_params={'ensure_ascii': False})
return render(request, 'portal/bind_wechat.html', {
'binding': None,
'bindings': bound_bindings,
'channel_gateway_mode': 'unavailable',
'plugin_qr_available': False,
'can_generate': False,
})
profile.channel_bindings.filter(
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_QR_PENDING,
is_deleted=False,
).update(status=ChannelBinding.STATUS_EXPIRED, error_message='superseded_by_new_qr', updated_at=timezone.now())
binding = create_wechat_qr_session(profile, generated_by=None, bind_source='user_self_service')
poll_qr_session.delay(binding.id)
write_audit(request=request, action='channel.qr.generate.self', target_type='ChannelBinding', target_id=binding.id)
if _is_ajax_request(request):
return JsonResponse(_wechat_qr_ajax_payload(binding), json_dumps_params={'ensure_ascii': False})
return render(request, 'portal/bind_wechat.html', {
'binding': binding,
'bindings': bound_bindings,
'channel_gateway_mode': settings.CHANNEL_GATEWAY_MODE,
'plugin_qr_available': True,
'remaining_seconds': _remaining_seconds(binding),
})
@login_required
@never_cache
def wechat_binding_status(request, binding_id):
binding = get_object_or_404(ChannelBinding, id=binding_id, channel_type=ChannelBinding.CHANNEL_WECHAT, is_deleted=False)
profile = getattr(request.user, 'user_profile', None)
owns_binding = profile and binding.user_profile_id == profile.id
if not owns_binding and not (request.user.is_staff or request.user.is_superuser):
raise Http404()
adapter_status = {}
now = timezone.now()
if wechat_qr_can_finish(binding, now=now):
try:
adapter_status = get_qr_session_status(binding.qr_session_id)
_apply_wechat_qr_status(binding, adapter_status, request=request)
binding.refresh_from_db()
except Exception as exc:
adapter_status = {'status': 'pending', 'error_message': str(exc)[:300]}
elif binding.status == ChannelBinding.STATUS_UNBOUND and (binding.error_message or '').strip() == 'duplicate_existing_chat_id':
try:
adapter_status = get_qr_session_status(binding.qr_session_id)
except Exception as exc:
adapter_status = {'status': 'unbound', 'error_message': str(exc)[:300]}
elif binding.status == ChannelBinding.STATUS_QR_PENDING and binding.qr_expires_at and binding.qr_expires_at <= now:
binding.status = ChannelBinding.STATUS_EXPIRED
binding.error_message = 'qr_expired'
binding.save(update_fields=['status', 'error_message', 'updated_at'])
duplicate_payload = _wechat_duplicate_bound_status_payload(binding, adapter_status)
if duplicate_payload:
return JsonResponse(duplicate_payload)
return JsonResponse(_wechat_binding_status_payload(binding, adapter_status))
@login_required
@require_http_methods(['POST'])
def set_primary_wechat(request, binding_id):
profile = getattr(request.user, 'user_profile', None)
if not profile:
messages.error(request, '当前账号没有关联用户资料。')
return redirect('/portal/me/')
binding = get_object_or_404(
ChannelBinding,
id=binding_id,
user_profile=profile,
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_BOUND,
is_deleted=False,
)
before = {
'is_primary': binding.is_primary,
'external_chat_id': binding.external_chat_id,
}
ChannelBinding.objects.filter(
user_profile=profile,
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_BOUND,
is_deleted=False,
is_primary=True,
).exclude(pk=binding.pk).update(is_primary=False, updated_at=timezone.now())
if not binding.is_primary:
binding.is_primary = True
binding.save(update_fields=['is_primary', 'updated_at'])
write_audit(
request=request,
action='channel.wechat.primary.self',
target_type='ChannelBinding',
target_id=binding.id,
before=before,
after={'is_primary': True, 'external_chat_id': binding.external_chat_id},
)
messages.success(request, '已设置为主通信渠道。')
return redirect('/portal/me/#channels')
@login_required
@require_http_methods(['POST'])
def update_wechat_remark(request, binding_id):
profile = getattr(request.user, 'user_profile', None)
if not profile:
messages.error(request, '当前账号没有关联用户资料。')
return redirect('/portal/me/')
binding = get_object_or_404(
ChannelBinding,
id=binding_id,
user_profile=profile,
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_BOUND,
is_deleted=False,
)
remark = str(request.POST.get('remark') or '').strip()[:128]
before = {
'remark': binding.remark,
'external_chat_id': binding.external_chat_id,
}
if binding.remark != remark:
binding.remark = remark
binding.save(update_fields=['remark', 'updated_at'])
write_audit(
request=request,
action='channel.wechat.remark.self',
target_type='ChannelBinding',
target_id=binding.id,
before=before,
after={'remark': binding.remark, 'external_chat_id': binding.external_chat_id},
)
messages.success(request, '渠道备注已保存。')
return redirect('/portal/me/#channels')
@login_required
@require_http_methods(['POST'])
def unbind_wechat(request, binding_id):
profile = getattr(request.user, 'user_profile', None)
if not profile:
messages.error(request, '当前账号没有关联用户资料。')
return redirect('/portal/me/')
binding = get_object_or_404(
ChannelBinding,
id=binding_id,
user_profile=profile,
channel_type=ChannelBinding.CHANNEL_WECHAT,
is_deleted=False,
)
now = timezone.now()
before = {
'status': binding.status,
'external_channel_id': binding.external_channel_id,
'external_chat_id': binding.external_chat_id,
'qr_session_id': binding.qr_session_id,
'is_primary': binding.is_primary,
}
changed = binding.status not in {
ChannelBinding.STATUS_UNBOUND,
ChannelBinding.STATUS_EXPIRED,
ChannelBinding.STATUS_DISABLED,
ChannelBinding.STATUS_FAILED,
}
external_chat_ids = [binding.external_chat_id] if binding.external_chat_id else []
qr_session_ids = [binding.qr_session_id] if binding.qr_session_id else []
was_primary = binding.is_primary
delete_memory = str(request.POST.get('delete_memory') or '').lower() in {'1', 'true', 'yes', 'on'}
memory_clear_result = None
memory_clear_error = ''
if changed and delete_memory and binding.status == ChannelBinding.STATUS_BOUND:
try:
memory_clear_result = _clear_wechat_channel_memory(binding, request=request)
write_audit(
request=request,
action='channel.wechat.memory_clear.self',
target_type='ChannelBinding',
target_id=binding.id,
before={
'status': binding.status,
'external_chat_id': binding.external_chat_id,
'qr_session_id': binding.qr_session_id,
},
after=memory_clear_result,
)
except Exception as exc:
memory_clear_error = str(exc)[:300]
write_audit(
request=request,
action='channel.wechat.memory_clear.self',
target_type='ChannelBinding',
target_id=binding.id,
before={
'status': binding.status,
'external_chat_id': binding.external_chat_id,
'qr_session_id': binding.qr_session_id,
},
after={'status': 'failed', 'error': memory_clear_error},
result='blocked',
error_message=memory_clear_error,
)
if changed:
binding.status = ChannelBinding.STATUS_UNBOUND
binding.unbound_at = now
binding.external_channel_id = ''
binding.external_chat_id = ''
binding.is_primary = False
binding.error_message = 'cancelled_by_user'
binding.save(update_fields=['status', 'unbound_at', 'external_channel_id', 'external_chat_id', 'is_primary', 'error_message', 'updated_at'])
write_audit(
request=request,
action='channel.wechat.unbind.self',
target_type='ChannelBinding',
target_id=binding.id,
before=before,
after={'status': binding.status, 'is_primary': binding.is_primary},
)
if was_primary:
_promote_primary_channel(profile, ChannelBinding.CHANNEL_WECHAT, request=request)
if changed:
try:
_revoke_wechat_adapter_sessions(profile, external_chat_ids, request=request, qr_session_ids=qr_session_ids)
except Exception as exc:
write_audit(request=request, action='channel.wechat.adapter_revoke', target_type='UserProfile', target_id=profile.id, result='blocked', error_message=str(exc)[:300])
if delete_memory and not memory_clear_error:
messages.success(request, '个人微信已解除绑定,且该渠道记忆已删除。')
elif delete_memory and memory_clear_error:
messages.warning(request, '个人微信已解除绑定,但该渠道记忆删除失败,请稍后重试。')
else:
messages.success(request, '个人微信已解除绑定,渠道记忆已保留。')
else:
messages.info(request, '个人微信当前未绑定。')
return redirect('/portal/me/')
@login_required
@require_http_methods(['POST'])
def clear_wechat_channel_memory(request, binding_id):
profile = getattr(request.user, 'user_profile', None)
if not profile:
messages.error(request, '\u5f53\u524d\u8d26\u53f7\u6ca1\u6709\u5173\u8054\u7528\u6237\u8d44\u6599\u3002')
return redirect('/portal/me/')
binding = get_object_or_404(
ChannelBinding,
id=binding_id,
user_profile=profile,
channel_type=ChannelBinding.CHANNEL_WECHAT,
status=ChannelBinding.STATUS_BOUND,
is_deleted=False,
)
result = {}
try:
result = _clear_wechat_channel_memory(binding, request=request)
messages.success(request, '\u8be5\u6e20\u9053\u7684\u4f1a\u8bdd\u8bb0\u5fc6\u5df2\u6e05\u7406\u3002')
audit_result = 'success'
error_message = ''
except Exception as exc:
messages.error(request, '\u8be5\u6e20\u9053\u7684\u4f1a\u8bdd\u8bb0\u5fc6\u6e05\u7406\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002')
result = {'status': 'failed', 'error': str(exc)[:300]}
audit_result = 'blocked'
error_message = str(exc)[:300]
write_audit(
request=request,
action='channel.wechat.memory_clear.self',
target_type='ChannelBinding',
target_id=binding.id,
before={
'status': binding.status,
'external_chat_id': binding.external_chat_id,
'qr_session_id': binding.qr_session_id,
},
after=result,
result=audit_result,
error_message=error_message,
)
return redirect('/portal/me/#channels')
@login_required
def wechat_qr_image(request, binding_id):
binding = get_object_or_404(ChannelBinding, id=binding_id, channel_type=ChannelBinding.CHANNEL_WECHAT)
profile = getattr(request.user, 'user_profile', None)
owns_binding = profile and binding.user_profile_id == profile.id
if not owns_binding and not (request.user.is_staff or request.user.is_superuser):
raise Http404()
if settings.CHANNEL_GATEWAY_MODE == 'mock' or not _real_wechat_qr_available():
raise Http404()
resp = requests.get(
f'{settings.CHANNEL_GATEWAY_BASE_URL}/internal/channels/wechat/qr-session/{binding.qr_session_id}/qr.png',
headers={'Authorization': f'Bearer {settings.CHANNEL_GATEWAY_TOKEN}'},
timeout=15,
)
resp.raise_for_status()
return HttpResponse(resp.content, content_type=resp.headers.get('content-type', 'image/png'))