ordloom-wechat 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: ordloom-wechat
3
+ Version: 0.1.0
4
+ Summary: Ordloom 微信公众号、小程序接入与会员绑定子应用
5
+ Project-URL: Homepage, https://neekin.github.io/ordloom-docs/
6
+ Project-URL: Documentation, https://neekin.github.io/ordloom-docs/guides/wechat.html
7
+ Project-URL: Repository, https://github.com/neekin/simpleui-antd
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: ordloom<2,>=1.1.0
11
+ Requires-Dist: ordloom-members<0.2,>=0.1.0
12
+ Requires-Dist: cryptography<51,>=43
13
+ Requires-Dist: defusedxml<0.8,>=0.7
14
+
15
+ # ordloom-wechat
16
+
17
+ 基于 Ordloom 子应用 SDK 和 ordloom-members 的微信接入 MVP:公众号/小程序渠道、消息回调验签与 AES 解密、去重收件记录、公众号 OAuth、小程序 code2Session、显式会员绑定/解绑及已绑定会员登录。
18
+
19
+ 版本 0.1.0,依赖 Ordloom 1.1.0+ 的 SDK v1。独立安装:
20
+
21
+ ```bash
22
+ python -m pip install ordloom-wechat==0.1.0
23
+ ```
24
+
25
+ 注册 `ordloom_members.apps.ExtensionConfig` 与 `ordloom_wechat.apps.ExtensionConfig`,挂载应用 URLs,执行迁移与静态收集。模块及渠道默认关闭;AppSecret、回调 Token、AES 密钥仅从宿主服务端设置读取,不通过后台下发。
26
+
27
+ 不包含微信支付、群发/客服发送、自动会员注册、UnionID 自动合并、第三方代授权或完整小程序前端。接入及真实平台联调要求见 [微信指南](https://neekin.github.io/ordloom-docs/guides/wechat.html)。
@@ -0,0 +1,13 @@
1
+ # ordloom-wechat
2
+
3
+ 基于 Ordloom 子应用 SDK 和 ordloom-members 的微信接入 MVP:公众号/小程序渠道、消息回调验签与 AES 解密、去重收件记录、公众号 OAuth、小程序 code2Session、显式会员绑定/解绑及已绑定会员登录。
4
+
5
+ 版本 0.1.0,依赖 Ordloom 1.1.0+ 的 SDK v1。独立安装:
6
+
7
+ ```bash
8
+ python -m pip install ordloom-wechat==0.1.0
9
+ ```
10
+
11
+ 注册 `ordloom_members.apps.ExtensionConfig` 与 `ordloom_wechat.apps.ExtensionConfig`,挂载应用 URLs,执行迁移与静态收集。模块及渠道默认关闭;AppSecret、回调 Token、AES 密钥仅从宿主服务端设置读取,不通过后台下发。
12
+
13
+ 不包含微信支付、群发/客服发送、自动会员注册、UnionID 自动合并、第三方代授权或完整小程序前端。接入及真实平台联调要求见 [微信指南](https://neekin.github.io/ordloom-docs/guides/wechat.html)。
@@ -0,0 +1 @@
1
+ """Optional WeChat application for Ordloom."""
@@ -0,0 +1,94 @@
1
+ from django.contrib import admin,messages
2
+ from django.core.exceptions import ValidationError
3
+ from django.db import transaction
4
+ from .configuration import enabled,WechatError
5
+ from .models import Channel,IdentityBinding,BindingEvent,CallbackReceipt
6
+ from .services import unlink,database_guard
7
+
8
+
9
+ class EnabledAdmin(admin.ModelAdmin):
10
+ def has_module_permission(self,request):
11
+ return enabled() and super().has_module_permission(request)
12
+ def has_view_permission(self,request,obj=None):
13
+ return self.has_module_permission(request) and super().has_view_permission(request,obj)
14
+ def has_change_permission(self,request,obj=None):
15
+ return self.has_module_permission(request) and super().has_change_permission(request,obj)
16
+ def has_add_permission(self,request):
17
+ return self.has_module_permission(request) and super().has_add_permission(request)
18
+ def has_delete_permission(self,request,obj=None):
19
+ return self.has_module_permission(request) and super().has_delete_permission(request,obj)
20
+
21
+
22
+ @admin.register(Channel)
23
+ class ChannelAdmin(EnabledAdmin):
24
+ fields=('name','kind','appid','original_id','credential_ref','enabled','callbacks_enabled','binding_enabled','login_enabled','encryption')
25
+ list_display=('name','kind','appid','enabled','callbacks_enabled','binding_enabled','login_enabled')
26
+ search_fields=('name','appid')
27
+ list_filter=('kind','enabled')
28
+ def has_add_permission(self,request):
29
+ return super().has_add_permission(request) and request.user.has_perm('ordloom_wechat.configure_channel')
30
+ def get_readonly_fields(self,request,obj=None):
31
+ fields=('kind','appid') if obj else ()
32
+ if not request.user.has_perm('ordloom_wechat.configure_channel'):
33
+ fields=tuple(name for name in self.fields if name!='name')
34
+ return fields
35
+ def save_model(self,request,obj,form,change):
36
+ database_guard()
37
+ with transaction.atomic():
38
+ if change and not request.user.has_perm('ordloom_wechat.configure_channel'):
39
+ old=Channel.objects.select_for_update().get(pk=obj.pk)
40
+ for field in self.fields:
41
+ if field!='name':setattr(obj,field,getattr(old,field))
42
+ super().save_model(request,obj,form,change)
43
+
44
+
45
+ class ReadonlyAdmin(EnabledAdmin):
46
+ def has_add_permission(self,request):return False
47
+ def has_change_permission(self,request,obj=None):return False
48
+ def has_delete_permission(self,request,obj=None):return False
49
+ def get_readonly_fields(self,request,obj=None):return tuple(field.name for field in self.model._meta.fields)
50
+
51
+
52
+ class ChannelChildAdmin(ReadonlyAdmin):
53
+ def get_queryset(self,request):
54
+ rows=super().get_queryset(request);manager=admin.site._registry[Channel]
55
+ return rows.filter(channel__in=manager.get_queryset(request)) if manager.has_view_permission(request) else rows.none()
56
+ def has_view_permission(self,request,obj=None):
57
+ manager=admin.site._registry[Channel]
58
+ return super().has_view_permission(request,obj) and (obj is None or manager.has_view_permission(request,obj.channel) and manager.get_queryset(request).filter(pk=obj.channel_id).exists())
59
+
60
+
61
+ @admin.register(IdentityBinding)
62
+ class IdentityBindingAdmin(ChannelChildAdmin):
63
+ list_display=('public_id','channel','member','bound_at')
64
+ search_fields=('public_id','openid')
65
+ list_filter=('channel',)
66
+ actions=('revoke_bindings',)
67
+ def has_revoke_permission(self,request,obj=None):
68
+ return self.has_view_permission(request,obj) and request.user.has_perm('ordloom_wechat.revoke_identitybinding')
69
+ @admin.action(description='撤销微信会员绑定',permissions=['revoke'])
70
+ def revoke_bindings(self,request,queryset):
71
+ try:
72
+ with transaction.atomic():
73
+ for binding in queryset.order_by('channel_id','pk'):
74
+ unlink(request,binding.public_id,staff=True)
75
+ except WechatError as error:raise ValidationError(error.message) from None
76
+ self.message_user(request,'绑定已撤销,微信来源会员会话下次访问时失效',level=messages.SUCCESS)
77
+
78
+
79
+ @admin.register(CallbackReceipt)
80
+ class CallbackReceiptAdmin(ChannelChildAdmin):
81
+ list_display=('channel','message_type','event','message_id','encrypted','received_at')
82
+ list_filter=('channel','message_type','encrypted')
83
+ search_fields=('message_id','event','content')
84
+
85
+
86
+ @admin.register(BindingEvent)
87
+ class BindingEventAdmin(ReadonlyAdmin):
88
+ list_display=('binding','member','action','created_at')
89
+ def get_queryset(self,request):
90
+ rows=super().get_queryset(request);manager=admin.site._registry[IdentityBinding]
91
+ return rows.filter(binding__in=manager.get_queryset(request)) if manager.has_view_permission(request) else rows.none()
92
+ def has_view_permission(self,request,obj=None):
93
+ manager=admin.site._registry[IdentityBinding]
94
+ return super().has_view_permission(request,obj) and (obj is None or manager.has_view_permission(request,obj.binding) and manager.get_queryset(request).filter(pk=obj.binding_id).exists())
@@ -0,0 +1,21 @@
1
+ from django.apps import AppConfig
2
+ from django.conf import settings
3
+
4
+
5
+ class ExtensionConfig(AppConfig):
6
+ name = 'ordloom_wechat'
7
+ verbose_name = '微信接入'
8
+ default_auto_field = 'django.db.models.BigAutoField'
9
+ ordloom_catalog_application = 'ordloom_wechat.extension.application'
10
+
11
+ @property
12
+ def ordloom_application(self):
13
+ from .configuration import enabled
14
+ if not enabled():
15
+ return None
16
+ return 'ordloom_wechat.extension.application'
17
+
18
+ def ready(self):
19
+ from ordloom_members.sessions import register_session_provider
20
+ from .services import session_valid
21
+ register_session_provider('wechat', session_valid)
@@ -0,0 +1,81 @@
1
+ import base64
2
+ import re
3
+ from urllib.parse import urlsplit
4
+ from django.conf import settings
5
+ from django.utils.crypto import salted_hmac
6
+
7
+ DEFAULTS = {'enabled': False, 'public_origin': '', 'credentials': {}, 'per_minute': 20,
8
+ 'callback_per_minute': 600, 'timestamp_skew': 300, 'receipt_days': 7}
9
+
10
+
11
+ class WechatError(Exception):
12
+ def __init__(self, message, status=400):
13
+ self.message = message
14
+ self.status = status
15
+ super().__init__(message)
16
+
17
+
18
+ def options():
19
+ raw = getattr(settings, 'ORDLOOM_WECHAT', {})
20
+ if not isinstance(raw, dict) or set(raw) - set(DEFAULTS):
21
+ raise WechatError('微信接入配置无效', 503)
22
+ value = {**DEFAULTS, **raw}
23
+ if type(value['enabled']) is not bool or not isinstance(value['credentials'], dict):
24
+ raise WechatError('微信接入配置无效', 503)
25
+ for key, low, high in [('per_minute', 1, 120), ('callback_per_minute', 1, 10000), ('timestamp_skew', 60, 600), ('receipt_days', 1, 90)]:
26
+ if type(value[key]) is not int or not low <= value[key] <= high:
27
+ raise WechatError('微信接入预算配置无效', 503)
28
+ return value
29
+
30
+
31
+ def enabled():
32
+ from django.apps import apps
33
+ return apps.is_installed('ordloom_wechat') and not {'wechat', 'members'} & set(getattr(settings, 'ORDLOOM_DISABLED_APPS', [])) and options()['enabled']
34
+
35
+
36
+ def require_enabled():
37
+ if not enabled():
38
+ raise WechatError('微信接入未启用', 404)
39
+
40
+
41
+ def public_origin():
42
+ value = options()['public_origin']
43
+ if not isinstance(value, str):
44
+ raise WechatError('请配置微信接入公开地址', 503)
45
+ parts = urlsplit(value)
46
+ local = getattr(settings, 'DEBUG', False) and parts.hostname in ('localhost', '127.0.0.1', '::1')
47
+ if parts.scheme not in (('https', 'http') if local else ('https',)) or not parts.netloc or parts.username or parts.password or parts.path not in ('', '/') or parts.query or parts.fragment:
48
+ raise WechatError('微信接入公开地址须为可信 HTTPS 源站', 503)
49
+ return value.rstrip('/')
50
+
51
+
52
+ def credentials_for(channel):
53
+ data = options()['credentials'].get(channel.credential_ref)
54
+ if not isinstance(data, dict) or data.get('appid') != channel.appid:
55
+ raise WechatError('渠道服务端凭据未配置或不匹配', 503)
56
+ cleaned = {'appid': channel.appid}
57
+ for name in ('app_secret', 'token', 'aes_key'):
58
+ value = data.get(name, '')
59
+ if not isinstance(value, str) or len(value) > 256 or any(ord(c) <= 32 for c in value):
60
+ raise WechatError('渠道服务端凭据格式无效', 503)
61
+ cleaned[name] = value
62
+ if (channel.binding_enabled or channel.login_enabled) and not cleaned['app_secret']:
63
+ raise WechatError('渠道身份接入凭据未配置', 503)
64
+ if channel.callbacks_enabled:
65
+ if not re.fullmatch(r'[A-Za-z0-9]{3,32}', cleaned['token']) or not re.fullmatch(r'[A-Za-z0-9_-]{3,128}', channel.original_id):
66
+ raise WechatError('渠道回调凭据或原始 ID 无效', 503)
67
+ if channel.encryption == 'aes':
68
+ try:
69
+ if len(cleaned['aes_key']) != 43 or len(base64.b64decode(cleaned['aes_key']+'=', validate=True)) != 32:
70
+ raise ValueError()
71
+ except Exception:
72
+ raise WechatError('渠道 AES 密钥格式无效', 503) from None
73
+ return cleaned
74
+
75
+
76
+ def config_stamp(channel):
77
+ import json
78
+ values = {key: getattr(channel, key) for key in ('appid', 'kind', 'credential_ref', 'enabled', 'callbacks_enabled', 'binding_enabled', 'login_enabled', 'encryption', 'original_id')}
79
+ values['credentials'] = credentials_for(channel)
80
+ values['public_origin'] = options()['public_origin']
81
+ return salted_hmac('ordloom-wechat-config', json.dumps(values, sort_keys=True)).hexdigest()
@@ -0,0 +1,29 @@
1
+ from django.contrib import admin
2
+ from django.contrib.admin.utils import flatten_fieldsets
3
+ from django.core.exceptions import PermissionDenied
4
+ from django.urls import reverse,NoReverseMatch
5
+ from ordloom.sdk import Application,AppPage,CanvasWidget,AppTask,visible_objects
6
+ from .models import Channel
7
+ from .services import maintenance
8
+
9
+
10
+ def overview(request):
11
+ items=[];manager=admin.site._registry[Channel]
12
+ for row in visible_objects(request,Channel):
13
+ if not {'name','appid','kind','enabled','callbacks_enabled','binding_enabled','login_enabled'}<=set(flatten_fieldsets(manager.get_fieldsets(request,row))):
14
+ raise PermissionDenied('渠道字段当前不可读取')
15
+ try:callback=reverse('ordloom_wechat:callback',kwargs={'channel_id':row.public_id})
16
+ except NoReverseMatch:callback=None
17
+ items.append({'id':str(row.public_id),'name':row.name,'appid':row.appid,'kind':row.get_kind_display(),'enabled':row.enabled,
18
+ 'callbacks_enabled':row.callbacks_enabled,'binding_enabled':row.binding_enabled,'login_enabled':row.login_enabled,'callback_url':callback})
19
+ try:portal=reverse('ordloom_wechat:portal')
20
+ except NoReverseMatch:portal=None
21
+ return {'title':'微信接入概览','items':items,'portal_url':portal}
22
+
23
+
24
+ application=Application(id='wechat',label='微信接入',version='0.1.0',asset='ordloom_wechat/extension.js', description='公众号与小程序身份接入、消息回调验签和会员绑定。', category='channel', distribution='ordloom-wechat', documentation_url='https://neekin.github.io/ordloom-docs/guides/wechat.html',requires=('members',), requires_versions={'members':'>=0.1.0,<0.2.0'},
25
+ pages=(AppPage('overview','微信接入概览',overview,('ordloom_wechat.view_channel',),model='ordloom_wechat.channel'),),
26
+ widgets=(CanvasWidget('identitylink','微信会员入口',('ordloom_wechat.view_channel',),model='ordloom_wechat.channel',public=True,
27
+ fields={'title':{'type':'text','label':'入口标题','required':True,'max_length':100},'path':{'type':'text','label':'身份管理路径','required':True,'max_length':1000}},
28
+ defaults={'title':'管理微信会员身份','path':'/wechat/'}),),
29
+ tasks=(AppTask('cleanup','微信授权与收件留存维护',maintenance,('ordloom_wechat.view_channel','ordloom_wechat.view_callbackreceipt','ordloom_wechat.purge_callbackreceipt'),model='ordloom_wechat.channel'),))
@@ -0,0 +1,135 @@
1
+ # Generated by Django 5.2.19 on 2026-11-11 21:12
2
+
3
+ import django.core.validators
4
+ import django.db.models.deletion
5
+ import uuid
6
+ from django.conf import settings
7
+ from django.db import migrations, models
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ('ordloom_members', '0001_initial'),
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name='Channel',
22
+ fields=[
23
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24
+ ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
25
+ ('name', models.CharField(max_length=100, verbose_name='渠道名称')),
26
+ ('kind', models.CharField(choices=[('official', '公众号'), ('mini', '小程序')], max_length=12, verbose_name='类型')),
27
+ ('appid', models.CharField(max_length=32, unique=True, validators=[django.core.validators.RegexValidator('^wx[A-Za-z0-9]{16}$')], verbose_name='AppID')),
28
+ ('original_id', models.CharField(blank=True, max_length=128, verbose_name='微信原始 ID')),
29
+ ('credential_ref', models.SlugField(verbose_name='服务端凭据引用')),
30
+ ('enabled', models.BooleanField(default=False, verbose_name='启用')),
31
+ ('callbacks_enabled', models.BooleanField(default=False, verbose_name='消息回调')),
32
+ ('binding_enabled', models.BooleanField(default=False, verbose_name='允许新绑定')),
33
+ ('login_enabled', models.BooleanField(default=False, verbose_name='允许绑定会员登录')),
34
+ ('encryption', models.CharField(choices=[('aes', 'AES 安全模式'), ('plain', '明文模式')], default='aes', max_length=8, verbose_name='回调模式')),
35
+ ('updated_at', models.DateTimeField(auto_now=True)),
36
+ ],
37
+ options={
38
+ 'verbose_name': '微信渠道',
39
+ 'verbose_name_plural': '微信渠道',
40
+ 'permissions': [('configure_channel', '可配置微信渠道与接入开关')],
41
+ },
42
+ ),
43
+ migrations.CreateModel(
44
+ name='AuthFlow',
45
+ fields=[
46
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
47
+ ('state_hash', models.CharField(max_length=64, unique=True)),
48
+ ('session_hash', models.CharField(db_index=True, max_length=64)),
49
+ ('config_stamp', models.CharField(max_length=64)),
50
+ ('intent', models.CharField(max_length=8)),
51
+ ('member_auth_hash', models.CharField(blank=True, max_length=128)),
52
+ ('expires_at', models.DateTimeField(db_index=True)),
53
+ ('used_at', models.DateTimeField(null=True)),
54
+ ('member', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='ordloom_members.member')),
55
+ ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ordloom_wechat.channel')),
56
+ ],
57
+ ),
58
+ migrations.CreateModel(
59
+ name='IdentityBinding',
60
+ fields=[
61
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
62
+ ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
63
+ ('openid', models.CharField(editable=False, max_length=128, verbose_name='OpenID')),
64
+ ('unionid', models.CharField(blank=True, editable=False, max_length=128, verbose_name='UnionID(不自动合并)')),
65
+ ('session_version', models.PositiveIntegerField(default=1, editable=False)),
66
+ ('bound_at', models.DateTimeField(editable=False, null=True, verbose_name='最近绑定时间')),
67
+ ('updated_at', models.DateTimeField(auto_now=True)),
68
+ ('channel', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bindings', to='ordloom_wechat.channel', verbose_name='渠道')),
69
+ ('member', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wechat_bindings', to='ordloom_members.member', verbose_name='会员')),
70
+ ],
71
+ options={
72
+ 'verbose_name': '微信会员绑定',
73
+ 'verbose_name_plural': '微信会员绑定',
74
+ 'permissions': [('revoke_identitybinding', '可撤销微信会员绑定')],
75
+ },
76
+ ),
77
+ migrations.CreateModel(
78
+ name='BindingEvent',
79
+ fields=[
80
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
81
+ ('action', models.CharField(choices=[('bind', '绑定'), ('unbind', '会员解绑'), ('revoke', '后台撤销')], max_length=12, verbose_name='操作')),
82
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发生时间')),
83
+ ('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
84
+ ('member', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='ordloom_members.member')),
85
+ ('binding', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='ordloom_wechat.identitybinding')),
86
+ ],
87
+ options={
88
+ 'verbose_name': '微信绑定记录',
89
+ 'verbose_name_plural': '微信绑定记录',
90
+ },
91
+ ),
92
+ migrations.CreateModel(
93
+ name='CallbackReceipt',
94
+ fields=[
95
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
96
+ ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
97
+ ('dedupe_key', models.CharField(editable=False, max_length=64)),
98
+ ('body_digest', models.CharField(editable=False, max_length=64, verbose_name='内容摘要')),
99
+ ('sender', models.CharField(editable=False, max_length=128, verbose_name='发送方 OpenID')),
100
+ ('message_id', models.CharField(blank=True, editable=False, max_length=128, verbose_name='消息编号')),
101
+ ('message_type', models.CharField(editable=False, max_length=40, verbose_name='消息类型')),
102
+ ('event', models.CharField(blank=True, editable=False, max_length=64, verbose_name='事件类型')),
103
+ ('content', models.TextField(blank=True, editable=False, max_length=4096, verbose_name='文本内容')),
104
+ ('encrypted', models.BooleanField(editable=False, verbose_name='已通过密文验签')),
105
+ ('received_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='接收时间')),
106
+ ('channel', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to='ordloom_wechat.channel', verbose_name='渠道')),
107
+ ],
108
+ options={
109
+ 'verbose_name': '微信回调收件',
110
+ 'verbose_name_plural': '微信回调收件',
111
+ 'permissions': [('purge_callbackreceipt', '可执行微信临时记录与收件留存清理')],
112
+ 'constraints': [models.UniqueConstraint(fields=('channel', 'dedupe_key'), name='ordloom_wx_callback_once')],
113
+ },
114
+ ),
115
+ migrations.CreateModel(
116
+ name='CodeUse',
117
+ fields=[
118
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
119
+ ('code_hash', models.CharField(max_length=64)),
120
+ ('expires_at', models.DateTimeField(db_index=True)),
121
+ ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ordloom_wechat.channel')),
122
+ ],
123
+ options={
124
+ 'constraints': [models.UniqueConstraint(fields=('channel', 'code_hash'), name='ordloom_wx_code_once')],
125
+ },
126
+ ),
127
+ migrations.AddConstraint(
128
+ model_name='identitybinding',
129
+ constraint=models.UniqueConstraint(fields=('channel', 'openid'), name='ordloom_wx_openid'),
130
+ ),
131
+ migrations.AddConstraint(
132
+ model_name='identitybinding',
133
+ constraint=models.UniqueConstraint(fields=('channel', 'member'), name='ordloom_wx_member'),
134
+ ),
135
+ ]
@@ -0,0 +1,118 @@
1
+ import uuid
2
+ from django.conf import settings
3
+ from django.core.exceptions import ValidationError
4
+ from django.core.validators import RegexValidator
5
+ from django.db import models, transaction
6
+
7
+
8
+ class Channel(models.Model):
9
+ public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
10
+ name = models.CharField('渠道名称', max_length=100)
11
+ kind = models.CharField('类型', max_length=12, choices=[('official', '公众号'), ('mini', '小程序')])
12
+ appid = models.CharField('AppID', max_length=32, unique=True, validators=[RegexValidator(r'^wx[A-Za-z0-9]{16}$')])
13
+ original_id = models.CharField('微信原始 ID', max_length=128, blank=True)
14
+ credential_ref = models.SlugField('服务端凭据引用', max_length=50)
15
+ enabled = models.BooleanField('启用', default=False)
16
+ callbacks_enabled = models.BooleanField('消息回调', default=False)
17
+ binding_enabled = models.BooleanField('允许新绑定', default=False)
18
+ login_enabled = models.BooleanField('允许绑定会员登录', default=False)
19
+ encryption = models.CharField('回调模式', max_length=8, choices=[('aes', 'AES 安全模式'), ('plain', '明文模式')], default='aes')
20
+ updated_at = models.DateTimeField(auto_now=True)
21
+
22
+ class Meta:
23
+ verbose_name = '微信渠道'
24
+ verbose_name_plural = verbose_name
25
+ permissions = [('configure_channel', '可配置微信渠道与接入开关')]
26
+
27
+ def __str__(self):
28
+ return self.name
29
+
30
+ def clean(self):
31
+ super().clean()
32
+ if self.enabled:
33
+ from .configuration import credentials_for, WechatError
34
+ try:
35
+ credentials_for(self)
36
+ except WechatError as error:
37
+ raise ValidationError(error.message) from None
38
+
39
+ def save(self, *args, **kwargs):
40
+ with transaction.atomic():
41
+ old = type(self).objects.select_for_update().filter(pk=self.pk).first() if self.pk else None
42
+ if old and (old.appid != self.appid or old.kind != self.kind):
43
+ raise ValidationError('已有渠道的 AppID 和类型固定,请新建渠道')
44
+ return super().save(*args, **kwargs)
45
+
46
+
47
+ class IdentityBinding(models.Model):
48
+ public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
49
+ channel = models.ForeignKey(Channel, on_delete=models.PROTECT, related_name='bindings', verbose_name='渠道')
50
+ openid = models.CharField('OpenID', max_length=128, editable=False)
51
+ unionid = models.CharField('UnionID(不自动合并)', max_length=128, blank=True, editable=False)
52
+ member = models.ForeignKey('ordloom_members.Member', on_delete=models.PROTECT, null=True, blank=True, related_name='wechat_bindings', verbose_name='会员')
53
+ session_version = models.PositiveIntegerField(default=1, editable=False)
54
+ bound_at = models.DateTimeField('最近绑定时间', null=True, editable=False)
55
+ updated_at = models.DateTimeField(auto_now=True)
56
+
57
+ class Meta:
58
+ verbose_name = '微信会员绑定'
59
+ verbose_name_plural = verbose_name
60
+ constraints = [models.UniqueConstraint(fields=['channel', 'openid'], name='ordloom_wx_openid'),
61
+ models.UniqueConstraint(fields=['channel', 'member'], name='ordloom_wx_member')]
62
+ permissions = [('revoke_identitybinding', '可撤销微信会员绑定')]
63
+
64
+ def __str__(self):
65
+ return str(self.public_id)
66
+
67
+
68
+ class BindingEvent(models.Model):
69
+ binding = models.ForeignKey(IdentityBinding, on_delete=models.PROTECT, related_name='events')
70
+ member = models.ForeignKey('ordloom_members.Member', on_delete=models.SET_NULL, null=True)
71
+ actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
72
+ action = models.CharField('操作', max_length=12, choices=[('bind', '绑定'), ('unbind', '会员解绑'), ('revoke', '后台撤销')])
73
+ created_at = models.DateTimeField('发生时间', auto_now_add=True)
74
+
75
+ class Meta:
76
+ verbose_name = '微信绑定记录'
77
+ verbose_name_plural = verbose_name
78
+
79
+
80
+ class AuthFlow(models.Model):
81
+ channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
82
+ state_hash = models.CharField(max_length=64, unique=True)
83
+ session_hash = models.CharField(max_length=64, db_index=True)
84
+ config_stamp = models.CharField(max_length=64)
85
+ intent = models.CharField(max_length=8)
86
+ member = models.ForeignKey('ordloom_members.Member', on_delete=models.CASCADE, null=True)
87
+ member_auth_hash = models.CharField(max_length=128, blank=True)
88
+ expires_at = models.DateTimeField(db_index=True)
89
+ used_at = models.DateTimeField(null=True)
90
+
91
+
92
+ class CodeUse(models.Model):
93
+ channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
94
+ code_hash = models.CharField(max_length=64)
95
+ expires_at = models.DateTimeField(db_index=True)
96
+
97
+ class Meta:
98
+ constraints = [models.UniqueConstraint(fields=['channel', 'code_hash'], name='ordloom_wx_code_once')]
99
+
100
+
101
+ class CallbackReceipt(models.Model):
102
+ public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
103
+ channel = models.ForeignKey(Channel, on_delete=models.PROTECT, related_name='receipts', verbose_name='渠道')
104
+ dedupe_key = models.CharField(max_length=64, editable=False)
105
+ body_digest = models.CharField('内容摘要', max_length=64, editable=False)
106
+ sender = models.CharField('发送方 OpenID', max_length=128, editable=False)
107
+ message_id = models.CharField('消息编号', max_length=128, blank=True, editable=False)
108
+ message_type = models.CharField('消息类型', max_length=40, editable=False)
109
+ event = models.CharField('事件类型', max_length=64, blank=True, editable=False)
110
+ content = models.TextField('文本内容', blank=True, max_length=4096, editable=False)
111
+ encrypted = models.BooleanField('已通过密文验签', editable=False)
112
+ received_at = models.DateTimeField('接收时间', auto_now_add=True, db_index=True)
113
+
114
+ class Meta:
115
+ verbose_name = '微信回调收件'
116
+ verbose_name_plural = verbose_name
117
+ constraints = [models.UniqueConstraint(fields=['channel', 'dedupe_key'], name='ordloom_wx_callback_once')]
118
+ permissions = [('purge_callbackreceipt', '可执行微信临时记录与收件留存清理')]