开发了多角色登录与鉴权接口:实现了普通用户、企业和管理员的登录分流,并支持Token验证。

开发了权限控制接口:实现了通过数据库分配菜单权限节点,控制接口访问安全。
开发了实名认证中心:实现了个人身份证信息与企业营业执照的提交与审核接口。
开发了任务与协作大厅核心业务:实现了任务的发布、接单、状态流转以及专家邀约接口。
配置了全局环境变量与数据库引擎:集成了 PostgreSQL 数据库、Redis 缓存与 MinIO 对象存储。
This commit is contained in:
2026-04-28 16:32:02 +08:00
commit 23855ef0e4
94 changed files with 4950 additions and 0 deletions

0
system/__init__.py Normal file
View File

3
system/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
system/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SystemConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'system'

View File

@@ -0,0 +1,108 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Announcement',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=256)),
('content', models.TextField()),
('cover_url', models.CharField(blank=True, max_length=512, null=True)),
('is_published', models.BooleanField(default=False)),
('published_at', models.DateTimeField(blank=True, null=True)),
('is_deleted', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'announcements',
},
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=128)),
('resource', models.CharField(blank=True, max_length=128, null=True)),
('resource_id', models.CharField(blank=True, max_length=128, null=True)),
('detail', models.JSONField(default=dict)),
('ip', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'audit_logs',
},
),
migrations.CreateModel(
name='ModelToken',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('model_id', models.CharField(max_length=128)),
('model_name', models.CharField(blank=True, max_length=256, null=True)),
('token_value', models.TextField()),
('expires_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'model_tokens',
},
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('type', models.CharField(choices=[('SYSTEM', '系统通知'), ('CERTIFICATION', '认证通知'), ('TASK', '任务通知'), ('RESERVATION', '预约通知')], max_length=32)),
('title', models.CharField(max_length=256)),
('content', models.TextField()),
('related_id', models.CharField(blank=True, max_length=128, null=True)),
('is_read', models.BooleanField(default=False)),
('is_deleted', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'notifications',
},
),
migrations.CreateModel(
name='SmsVerification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phone', models.CharField(max_length=20)),
('scene', models.CharField(choices=[('REGISTER', '注册'), ('LOGIN', '登录'), ('RESET_PASSWORD', '重置密码')], max_length=32)),
('code', models.CharField(max_length=8)),
('is_used', models.BooleanField(default=False)),
('expired_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)),
('ip', models.GenericIPAddressField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'sms_verifications',
},
),
migrations.CreateModel(
name='SystemConfig',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('config_key', models.CharField(max_length=128, unique=True)),
('config_value', models.JSONField()),
('description', models.TextField(blank=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'system_configs',
},
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('system', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='systemconfig',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_configs', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='notification',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='modeltoken',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='model_tokens', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='auditlog',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='announcement',
name='publisher',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='published_announcements', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 4.2.30 on 2026-04-25 13:10
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('system', '0002_initial'),
]
operations = [
migrations.CreateModel(
name='AiModel',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=128)),
('provider', models.CharField(max_length=128)),
('description', models.TextField(blank=True, null=True)),
('icon', models.CharField(blank=True, max_length=64, null=True)),
('price_per_token', models.DecimalField(decimal_places=6, default=0, max_digits=10)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'ai_models',
},
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 4.2.30 on 2026-04-27 16:36
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('system', '0003_aimodel'),
]
operations = [
migrations.CreateModel(
name='Skill',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=64, unique=True)),
('category', models.CharField(blank=True, help_text='分类: 技术/设计/内容/数据 等', max_length=64, null=True)),
('sort_order', models.IntegerField(default=0, help_text='排序权重, 越小越靠前')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'skills',
'ordering': ['sort_order', 'name'],
},
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 4.2.30 on 2026-04-27 17:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0004_skill'),
]
operations = [
migrations.AlterModelOptions(
name='announcement',
options={'ordering': ['-is_recommended', '-recommend_priority', '-created_at']},
),
migrations.AddField(
model_name='announcement',
name='is_recommended',
field=models.BooleanField(default=False, help_text='推荐/置顶'),
),
migrations.AddField(
model_name='announcement',
name='recommend_priority',
field=models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-04-27 17:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0005_alter_announcement_options_and_more'),
]
operations = [
migrations.AddField(
model_name='announcement',
name='target_audience',
field=models.CharField(choices=[('ALL', '全平台'), ('USER', '用户端'), ('ENTERPRISE', '企业端')], default='ALL', help_text='公告受众', max_length=16),
),
]

View File

78
system/minio_utils.py Normal file
View File

@@ -0,0 +1,78 @@
from minio import Minio
from django.conf import settings
import uuid
import os
import json
import logging
logger = logging.getLogger(__name__)
class MinioClient:
def __init__(self):
self._client = None
self.bucket_name = getattr(settings, 'MINIO_BUCKET_NAME', 'opc-assets')
self._initialized = False
@property
def client(self):
if self._client is None:
self._client = Minio(
endpoint=getattr(settings, 'MINIO_ENDPOINT', 'localhost:9000'),
access_key=getattr(settings, 'MINIO_ACCESS_KEY', 'minioadmin'),
secret_key=getattr(settings, 'MINIO_SECRET_KEY', 'minioadmin'),
secure=getattr(settings, 'MINIO_SECURE', False)
)
if not self._initialized:
try:
self._ensure_bucket()
self._initialized = True
except Exception as e:
logger.warning(f'MinIO bucket init failed (will retry on next call): {e}')
return self._client
def _ensure_bucket(self):
if not self._client.bucket_exists(self.bucket_name):
self._client.make_bucket(self.bucket_name)
# Always update policy to ensure public access
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetBucketLocation", "s3:ListBucket"],
"Resource": [f"arn:aws:s3:::{self.bucket_name}"],
},
{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": [f"arn:aws:s3:::{self.bucket_name}/*"],
},
],
}
self._client.set_bucket_policy(self.bucket_name, json.dumps(policy))
def upload_file(self, file_obj, folder='general'):
# Generate a unique name
ext = os.path.splitext(file_obj.name)[1]
file_name = f"{folder}/{uuid.uuid4()}{ext}"
# Upload (this will trigger lazy init of the client)
self.client.put_object(
self.bucket_name,
file_name,
file_obj,
length=file_obj.size,
content_type=file_obj.content_type
)
# Return the public URL
base_url = getattr(settings, 'MINIO_PUBLIC_URL', 'http://127.0.0.1:9000')
return f"{base_url}/{self.bucket_name}/{file_name}"
# Lazy singleton — does NOT connect at import time
minio_client = MinioClient()

132
system/models.py Normal file
View File

@@ -0,0 +1,132 @@
import uuid
from django.db import models
class NotificationType(models.TextChoices):
SYSTEM = 'SYSTEM', '系统通知'
CERTIFICATION = 'CERTIFICATION', '认证通知'
TASK = 'TASK', '任务通知'
RESERVATION = 'RESERVATION', '预约通知'
class SmsScene(models.TextChoices):
REGISTER = 'REGISTER', '注册'
LOGIN = 'LOGIN', '登录'
RESET_PASSWORD = 'RESET_PASSWORD', '重置密码'
class AudienceChoices(models.TextChoices):
ALL = 'ALL', '全平台'
USER = 'USER', '用户端'
ENTERPRISE = 'ENTERPRISE', '企业端'
class Announcement(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(max_length=256)
content = models.TextField()
cover_url = models.CharField(max_length=512, null=True, blank=True)
publisher = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='published_announcements')
target_audience = models.CharField(max_length=16, choices=AudienceChoices.choices, default=AudienceChoices.ALL, help_text='公告受众')
is_published = models.BooleanField(default=False)
published_at = models.DateTimeField(null=True, blank=True)
is_deleted = models.BooleanField(default=False)
is_recommended = models.BooleanField(default=False, help_text='推荐/置顶')
recommend_priority = models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'announcements'
ordering = ['-is_recommended', '-recommend_priority', '-created_at']
class ModelToken(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='model_tokens')
model_id = models.CharField(max_length=128)
model_name = models.CharField(max_length=256, null=True, blank=True)
token_value = models.TextField()
expires_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'model_tokens'
class SmsVerification(models.Model):
phone = models.CharField(max_length=20)
scene = models.CharField(max_length=32, choices=SmsScene.choices)
code = models.CharField(max_length=8)
is_used = models.BooleanField(default=False)
expired_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
ip = models.GenericIPAddressField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'sms_verifications'
class AuditLog(models.Model):
user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='audit_logs')
action = models.CharField(max_length=128)
resource = models.CharField(max_length=128, null=True, blank=True)
resource_id = models.CharField(max_length=128, null=True, blank=True)
detail = models.JSONField(default=dict)
ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'audit_logs'
class Notification(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='notifications')
type = models.CharField(max_length=32, choices=NotificationType.choices)
title = models.CharField(max_length=256)
content = models.TextField()
related_id = models.CharField(max_length=128, null=True, blank=True)
is_read = models.BooleanField(default=False)
is_deleted = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'notifications'
class SystemConfig(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
config_key = models.CharField(max_length=128, unique=True)
config_value = models.JSONField()
description = models.TextField(null=True, blank=True)
updated_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='updated_configs')
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'system_configs'
class AiModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=128)
provider = models.CharField(max_length=128)
description = models.TextField(null=True, blank=True)
icon = models.CharField(max_length=64, null=True, blank=True)
price_per_token = models.DecimalField(max_digits=10, decimal_places=6, default=0)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'ai_models'
class Skill(models.Model):
"""Admin-managed skill/expertise domain tags used across the platform."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=64, unique=True)
category = models.CharField(max_length=64, null=True, blank=True, help_text='分类: 技术/设计/内容/数据 等')
sort_order = models.IntegerField(default=0, help_text='排序权重, 越小越靠前')
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'skills'
ordering = ['sort_order', 'name']
def __str__(self):
return self.name

36
system/serializers.py Normal file
View File

@@ -0,0 +1,36 @@
from rest_framework import serializers
from .models import Announcement, Notification, SystemConfig, AiModel, ModelToken, Skill
class AnnouncementSerializer(serializers.ModelSerializer):
class Meta:
model = Announcement
fields = '__all__'
class NotificationSerializer(serializers.ModelSerializer):
class Meta:
model = Notification
fields = '__all__'
read_only_fields = ['id', 'user', 'created_at']
class SystemConfigSerializer(serializers.ModelSerializer):
class Meta:
model = SystemConfig
fields = '__all__'
class AiModelSerializer(serializers.ModelSerializer):
class Meta:
model = AiModel
fields = '__all__'
class ModelTokenSerializer(serializers.ModelSerializer):
model_name = serializers.ReadOnlyField(source='model.name')
class Meta:
model = ModelToken
fields = '__all__'
read_only_fields = ['id', 'user', 'token_value', 'created_at']
class SkillSerializer(serializers.ModelSerializer):
class Meta:
model = Skill
fields = '__all__'
read_only_fields = ['id', 'created_at', 'updated_at']

3
system/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

17
system/urls.py Normal file
View File

@@ -0,0 +1,17 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import AnnouncementViewSet, NotificationViewSet, SystemConfigViewSet, AiModelViewSet, ModelTokenViewSet, FileUploadView, SkillViewSet
router = DefaultRouter()
router.register('announcements', AnnouncementViewSet, basename='announcement')
router.register('notifications', NotificationViewSet, basename='notification')
router.register('configs', SystemConfigViewSet, basename='config')
router.register('models', AiModelViewSet, basename='model')
router.register('tokens', ModelTokenViewSet, basename='model-token')
router.register('skills', SkillViewSet, basename='skill')
urlpatterns = [
path('upload/', FileUploadView.as_view(), name='file_upload'),
path('', include(router.urls)),
]

152
system/views.py Normal file
View File

@@ -0,0 +1,152 @@
from rest_framework import viewsets, generics, permissions, status
from rest_framework.response import Response
from rest_framework.decorators import action
from .models import Announcement, Notification, SystemConfig, AiModel, ModelToken, Skill
from .serializers import AnnouncementSerializer, NotificationSerializer, SystemConfigSerializer, AiModelSerializer, ModelTokenSerializer, SkillSerializer
class AnnouncementViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 系统公告接口视图。管理员发布全局或定向通知公告,前端获取并展示给对应受众。
"""
queryset = Announcement.objects.filter(is_deleted=False)
serializer_class = AnnouncementSerializer
def get_queryset(self):
qs = Announcement.objects.filter(is_deleted=False).order_by('-is_recommended', '-recommend_priority', '-created_at')
audience = self.request.query_params.get('audience')
if audience:
qs = qs.filter(target_audience__in=[audience, 'ALL'])
if self.action == 'list' and not self.request.user.is_staff:
qs = qs.filter(is_published=True)
return qs
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy', 'toggle_recommend']:
return [permissions.IsAdminUser()]
return [permissions.AllowAny()]
@action(detail=True, methods=['post'])
def toggle_recommend(self, request, pk=None):
ann = self.get_object()
ann.is_recommended = request.data.get('is_recommended', not ann.is_recommended)
ann.recommend_priority = request.data.get('recommend_priority', ann.recommend_priority)
ann.save(update_fields=['is_recommended', 'recommend_priority'])
return Response({'is_recommended': ann.is_recommended, 'recommend_priority': ann.recommend_priority})
class NotificationViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 站内消息通知接口视图。用于系统内部产生的各类站内信、状态变更通知的拉取和未读/已读状态更新。
"""
serializer_class = NotificationSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Notification.objects.filter(user=self.request.user, is_deleted=False)
@action(detail=True, methods=['post'])
def read(self, request, pk=None):
notification = self.get_object()
notification.is_read = True
notification.save()
return Response({'status': '已标记为已读'})
@action(detail=False, methods=['post'])
def read_all(self, request):
self.get_queryset().update(is_read=True)
return Response({'status': '全部标记为已读'})
class SystemConfigViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 系统全局动态配置接口视图。用于保存字典数据、开关配置等,避免硬编码参数。
"""
queryset = SystemConfig.objects.all()
serializer_class = SystemConfigSerializer
lookup_field = 'config_key'
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']:
return [permissions.IsAdminUser()]
return [permissions.AllowAny()]
class AiModelViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: AI模型配置接口视图。统一管理平台对接的各大AI平台如OpenAI、文心一言等的模型参数及端点。
"""
queryset = AiModel.objects.all()
serializer_class = AiModelSerializer
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']:
return [permissions.IsAdminUser()]
return [permissions.IsAuthenticatedOrReadOnly()]
def get_queryset(self):
qs = super().get_queryset()
if self.action == 'list' and not self.request.user.is_staff:
return qs.filter(is_active=True)
return qs
class ModelTokenViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 大模型Token/密钥接口视图。安全管理模型请求时使用的Token或API Key认证信息。
"""
serializer_class = ModelTokenSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return ModelToken.objects.filter(user=self.request.user)
def perform_create(self, serializer):
# In a real app, this would call MoRouter or another provider
# For now, we generate a mock token
import uuid
mock_token = f"sk-opc-{uuid.uuid4().hex}"
serializer.save(user=self.request.user, token_value=mock_token)
class SkillViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 技能字典/用户标签接口视图。用于系统内的技能标签池管理,以供任务画像和用户画像进行匹配。
"""
"""Admin-managed skill/expertise tags. Read-only for non-admins."""
queryset = Skill.objects.all()
serializer_class = SkillSerializer
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']:
return [permissions.IsAdminUser()]
return [permissions.AllowAny()]
def get_queryset(self):
qs = super().get_queryset()
if self.action == 'list' and not (self.request.user.is_authenticated and self.request.user.is_staff):
return qs.filter(is_active=True)
return qs
from .minio_utils import minio_client
class FileUploadView(generics.GenericAPIView):
"""
@author: xujl
Api说明: 系统通用文件上传接口。对接MinIO或S3等对象存储处理前端传入的文件并返回URL外链。
"""
permission_classes = [permissions.AllowAny]
def post(self, request):
file_obj = request.FILES.get('file')
if not file_obj:
return Response({'detail': '没有上传文件'}, status=status.HTTP_400_BAD_REQUEST)
folder = request.data.get('folder', 'general')
try:
file_url = minio_client.upload_file(file_obj, folder=folder)
return Response({'url': file_url}, status=status.HTTP_201_CREATED)
except Exception as e:
return Response({'detail': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)