1133 lines
46 KiB
Python
1133 lines
46 KiB
Python
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'))
|