""" API v1 通用模型 """ import os import uuid from django.db import models from django.db.models import Q from django.contrib.auth import get_user_model from django.db.models.fields.json import KeyTextTransform from django.utils import timezone from flower.common import ModelBase from basic_info import models as basic_info_models User = get_user_model() def upload_file_path(instance, filename): """ 生成随机化的文件上传路径 格式: uploads/YYYY/MM/DD/uuid.ext """ # 获取文件扩展名 ext = os.path.splitext(filename)[1].lower() # 生成随机文件名 random_filename = f"{uuid.uuid4().hex}{ext}" # 返回完整路径 from datetime import datetime now = datetime.now() return f"uploads/{now.year}/{now.month:02d}/{now.day:02d}/{random_filename}" def app_version_file_path(instance, filename): ext = os.path.splitext(filename)[1].lower() random_filename = f"{uuid.uuid4().hex}{ext}" return f"app_versions/{random_filename}" class AppVersion(ModelBase): major = models.PositiveIntegerField(default=0, verbose_name='主版本号') minor = models.PositiveIntegerField(default=0, verbose_name='次版本号') build = models.PositiveIntegerField(default=0, verbose_name='构建号') package_file = models.FileField( upload_to=app_version_file_path, max_length=500, blank=True, null=True, verbose_name='安装包文件', help_text='可通过 Admin 上传,保存后会自动写入下载地址', ) download_url = models.URLField(max_length=2048, blank=True, verbose_name='下载地址') force = models.BooleanField(default=False, verbose_name='是否强制更新') publish_date = models.DateField(default=timezone.localdate, verbose_name='发布日期') message = models.TextField(blank=True, verbose_name='更新说明') is_current = models.BooleanField(default=False, db_index=True, verbose_name='当前版本') class Meta: verbose_name = 'App版本' verbose_name_plural = 'App版本' ordering = ['-is_current', '-created_at'] constraints = [ models.UniqueConstraint( fields=['is_current'], condition=Q(is_current=True), name='uniq_current_app_version', ), ] def __str__(self): return f'{self.major}.{self.minor}.{self.build}' def save(self, *args, **kwargs): if self.package_file: self.download_url = self.package_file.url if self.is_current: AppVersion.objects.exclude(pk=self.pk).filter(is_current=True).update(is_current=False) super().save(*args, **kwargs) class UploadedFile(ModelBase): """ 通用文件上传记录 用于存储无法归类到具体业务的文件上传记录 """ path = models.FileField( upload_to=upload_file_path, max_length=500, verbose_name='文件路径', help_text='文件存储路径,包含前缀、随机文件名和后缀' ) owner = models.ForeignKey( User, on_delete=models.CASCADE, related_name='uploaded_files', verbose_name='上传者' ) is_deleted = models.BooleanField( default=False, verbose_name='是否已删除', help_text='软删除标记' ) original_filename = models.CharField( max_length=255, blank=True, verbose_name='原始文件名', help_text='用户上传时的原始文件名' ) file_size = models.BigIntegerField( null=True, blank=True, verbose_name='文件大小(字节)' ) content_type = models.CharField( max_length=100, blank=True, verbose_name='文件类型', help_text='MIME类型,如 image/jpeg' ) class Meta: db_table = 'api_uploaded_file' verbose_name = '上传文件' verbose_name_plural = '上传文件' ordering = ['-created_at'] def __str__(self): return f"{self.original_filename or self.path.name} (by {self.owner.username})" @property def file_url(self): """获取文件访问URL""" if self.path: return self.path.url return None class DataSync(ModelBase): """记录外部数据同步的执行情况""" class TableName(models.TextChoices): PRODUCT = 'product', '产品' CUSTOMER = 'customer', '客户' PLATE_ORDER = 'plate_order', '开版(明道云)' PRINTING_EXTERNAL_RECORD = 'printing_external_record', '印染订单(外部 records)' MDY_PLATE_ORDER_STAGING_TIIA_UPLOAD = 'mdy_plate_order_staging_tiia_upload', '开版暂存-腾讯图库上传' table_name = models.CharField(max_length=50, choices=TableName.choices, verbose_name='同步目标') merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, null=True, blank=True, related_name='data_sync_records', verbose_name='所属商户', ) page_index = models.PositiveIntegerField(default=1, verbose_name='页码') page_size = models.PositiveIntegerField(default=100, verbose_name='每页数量') total_count = models.PositiveIntegerField(default=0, verbose_name='远端总数') synced_rows = models.PositiveIntegerField(default=0, verbose_name='本次同步数量') last_ctime = models.DateTimeField(null=True, blank=True, verbose_name='最新数据时间') last_rowid = models.CharField(max_length=64, blank=True, verbose_name='最新 RowID') note = models.CharField(max_length=255, blank=True, verbose_name='备注') class Meta: db_table = 'api_data_sync' verbose_name = '数据同步记录' verbose_name_plural = '数据同步记录' ordering = ['-created_at'] def __str__(self): return f'{self.table_name} @ {self.created_at:%Y-%m-%d %H:%M:%S}' class MDYPlateOrderStaging(ModelBase): """明道云"开版数据表"同步暂存表(不影响 printing.PlateOrder)。 设计目标: - 与现有业务模型完全隔离,避免影响业务数据与流程 - 仅保留 rowid 便于检索排查,其余信息全部落在 raw(JSON) 中 - 随着同步深入再逐步"属性化"字段(从 raw 实时提取/映射) """ mdy_rowid = models.CharField( max_length=64, unique=True, db_index=True, verbose_name='明道云 RowID', help_text='明道云行记录的 rowid(全局唯一)', ) ctime = models.DateTimeField( null=True, blank=True, db_index=True, verbose_name='明道云创建时间', help_text='明道云系统字段 ctime(用于排序/增量定位)', ) utime = models.DateTimeField( null=True, blank=True, db_index=True, verbose_name='明道云更新时间', help_text='明道云系统字段 utime(用于排序/增量定位)', ) raw = models.JSONField( default=dict, blank=True, verbose_name='原始行数据', help_text='getFilterRows 返回的整行原始数据(包含各 controlId 字段)', ) related = models.JSONField( default=list, blank=True, verbose_name='关联表数据', help_text='开版相关跨表数据(碾平 list[dict]),用于排查与后续属性化提取', ) class Meta: db_table = 'api_mdy_plate_order_staging' verbose_name = '明道云开版暂存' verbose_name_plural = '明道云开版暂存' ordering = ['-created_at'] indexes = [ # 明道云字段:62d52f4b8d2972284492dd0e(开版表"订单id/设计编号") # 用于在暂存表中按该字段快速检索 models.Index( KeyTextTransform('62d52f4b8d2972284492dd0e', 'raw'), name='api_mdy_po_raw_id_idx', ), ] def __str__(self): return f'{self.mdy_rowid}' class MDYPlateOrderStagingTiiaUploadFailure(ModelBase): """ 明道云开版暂存(MDYPlateOrderStaging)图片上传到腾讯云 TIIA 失败记录。 说明: - 用于定时任务/批处理的失败落库,便于后续排查与补偿重试 - 以 (run_date, mdy_rowid) 唯一,避免同一天重复刷屏 """ run_date = models.DateField(db_index=True, verbose_name='任务日期') mdy_rowid = models.CharField(max_length=64, db_index=True, verbose_name='明道云 RowID') staging_id = models.PositiveBigIntegerField(null=True, blank=True, db_index=True, verbose_name='暂存表ID') error = models.TextField(blank=True, verbose_name='错误摘要') details = models.JSONField( default=list, blank=True, verbose_name='错误详情', help_text='通常存储上传 service 返回的失败条目列表', ) attempts = models.PositiveIntegerField(default=0, verbose_name='尝试次数') last_attempt_at = models.DateTimeField(null=True, blank=True, verbose_name='最后尝试时间') class Meta: db_table = 'api_mdy_plate_order_staging_tiia_upload_failure' verbose_name = '开版暂存图片上传失败记录' verbose_name_plural = '开版暂存图片上传失败记录' ordering = ['-created_at'] constraints = [ models.UniqueConstraint( fields=['run_date', 'mdy_rowid'], name='uniq_mdy_po_staging_tiia_fail_run_date_rowid', ) ] def __str__(self): return f'{self.run_date} {self.mdy_rowid}' class MDYPlateOrderStagingQiniuImageUploadAudit(ModelBase): """明道云开版暂存"开版图"迁移到七牛的逐图审计记录。""" class Status(models.TextChoices): PENDING = 'pending', '待处理' PROCESSING = 'processing', '处理中' SUCCESS = 'success', '成功' FAILED = 'failed', '失败' SKIPPED = 'skipped', '跳过' class Source(models.TextChoices): RAW_PLATE_IMAGES = 'raw_plate_images', 'raw.开版图' staging = models.ForeignKey( MDYPlateOrderStaging, on_delete=models.CASCADE, related_name='qiniu_image_upload_audits', verbose_name='开版暂存记录', ) mdy_rowid = models.CharField(max_length=64, db_index=True, verbose_name='明道云 RowID') source = models.CharField( max_length=50, choices=Source.choices, default=Source.RAW_PLATE_IMAGES, verbose_name='图片来源', ) attachment_index = models.PositiveIntegerField(verbose_name='附件序号') source_field = models.CharField(max_length=64, blank=True, verbose_name='原始 URL 字段') original_url = models.URLField(max_length=2048, verbose_name='原始图片 URL') original_host = models.CharField(max_length=255, blank=True, db_index=True, verbose_name='原始图片域名') qiniu_key = models.CharField(max_length=500, blank=True, verbose_name='七牛对象 Key') qiniu_url = models.URLField(max_length=2048, blank=True, verbose_name='七牛图片 URL') status = models.CharField( max_length=20, choices=Status.choices, default=Status.PENDING, db_index=True, verbose_name='上传状态', ) attempts = models.PositiveIntegerField(default=0, verbose_name='尝试次数') error = models.TextField(blank=True, verbose_name='错误信息') file_size = models.PositiveBigIntegerField(null=True, blank=True, verbose_name='文件大小') content_type = models.CharField(max_length=100, blank=True, verbose_name='Content-Type') raw_attachment = models.JSONField(default=dict, blank=True, verbose_name='原始附件快照') started_at = models.DateTimeField(null=True, blank=True, verbose_name='开始处理时间') uploaded_at = models.DateTimeField(null=True, blank=True, verbose_name='上传成功时间') last_attempt_at = models.DateTimeField(null=True, blank=True, verbose_name='最后尝试时间') class Meta: db_table = 'api_mdy_plate_order_staging_qiniu_image_upload_audit' verbose_name = '开版暂存开版图七牛上传审计' verbose_name_plural = '开版暂存开版图七牛上传审计' ordering = ['-created_at'] constraints = [ models.UniqueConstraint( fields=['staging', 'source', 'attachment_index', 'source_field', 'original_url'], name='uniq_mdy_qiniu_staging_src_idx_url', ) ] indexes = [ models.Index(fields=['status', 'id'], name='mdy_qiniu_status_id_idx'), models.Index(fields=['original_url'], name='mdy_qiniu_orig_url_idx'), models.Index(fields=['staging', 'source'], name='mdy_qiniu_staging_src_idx'), ] def __str__(self): return f'{self.mdy_rowid}#{self.attachment_index} {self.status}' class MDYPlateOrderStagingQiniuImageUploadState(ModelBase): """明道云开版暂存开版图上传任务的持久扫描游标。""" name = models.CharField(max_length=80, unique=True, verbose_name='任务名称') last_staging_id = models.PositiveBigIntegerField(default=0, verbose_name='已扫描到的暂存表ID') staging_finished = models.BooleanField(default=False, verbose_name='暂存表是否扫描完成') scanned_staging_rows = models.PositiveBigIntegerField(default=0, verbose_name='已扫描暂存行数') discovered_image_items = models.PositiveBigIntegerField(default=0, verbose_name='发现图片数') created_audit_rows = models.PositiveBigIntegerField(default=0, verbose_name='创建审计数') existing_audit_rows = models.PositiveBigIntegerField(default=0, verbose_name='已存在审计数') last_heartbeat_at = models.DateTimeField(null=True, blank=True, verbose_name='最后心跳时间') class Meta: db_table = 'api_mdy_plate_order_staging_qiniu_image_upload_state' verbose_name = '开版暂存开版图七牛上传状态' verbose_name_plural = '开版暂存开版图七牛上传状态' ordering = ['name'] def __str__(self): return f'{self.name}: staging_id={self.last_staging_id}' class PrintingExternalSyncFailure(ModelBase): """ 外部印染 records 同步失败记录。 以外部 record.ID 为追踪主键,允许同一天重复失败时累加 attempts。 """ run_date = models.DateField(db_index=True, verbose_name='任务日期') external_record_id = models.PositiveBigIntegerField(db_index=True, verbose_name='外部记录ID') external_order_id = models.CharField(max_length=100, blank=True, verbose_name='外部订单编号') product_name = models.CharField(max_length=200, blank=True, verbose_name='外部产品名') error = models.TextField(blank=True, verbose_name='错误摘要') raw = models.JSONField(default=dict, blank=True, verbose_name='原始数据') attempts = models.PositiveIntegerField(default=0, verbose_name='尝试次数') last_attempt_at = models.DateTimeField(null=True, blank=True, verbose_name='最后尝试时间') class Meta: db_table = 'api_printing_external_sync_failure' verbose_name = '外部印染同步失败记录' verbose_name_plural = '外部印染同步失败记录' ordering = ['-created_at'] constraints = [ models.UniqueConstraint( fields=['run_date', 'external_record_id'], name='uniq_printing_ext_sync_fail_run_date_record', ) ] def __str__(self): return f'{self.external_record_id} @ {self.run_date}' class ApiAuditLog(models.Model): """API审计日志 - 记录创建/更新操作的历史现场 用于保存特定模块的POST/PUT/PATCH请求,记录"创建/更新"操作的完整请求信息。 通过Celery异步写入,避免影响API响应性能。 """ url = models.CharField( max_length=500, verbose_name='请求URL', help_text='完整的请求路径' ) method = models.CharField( max_length=10, verbose_name='请求方法', help_text='HTTP方法,如POST、PUT、PATCH' ) request_data = models.JSONField( default=dict, verbose_name='请求体数据', help_text='请求的body数据(JSON格式)' ) query_params = models.JSONField( default=dict, verbose_name='查询参数', help_text='URL查询参数' ) user_id = models.IntegerField( null=True, blank=True, db_index=True, verbose_name='用户ID', help_text='操作用户的ID' ) username = models.CharField( max_length=150, blank=True, default='', verbose_name='用户名', help_text='操作用户的用户名快照' ) response_status = models.IntegerField( null=True, blank=True, verbose_name='响应状态码', help_text='HTTP响应状态码' ) created_at = models.DateTimeField( auto_now_add=True, db_index=True, verbose_name='创建时间' ) class Meta: db_table = 'api_audit_log' verbose_name = 'API审计日志' verbose_name_plural = 'API审计日志' ordering = ['-created_at'] indexes = [ models.Index(fields=['user_id', 'created_at']), models.Index(fields=['url', 'created_at']), ] def __str__(self): return f'{self.method} {self.url} ({self.username or "anonymous"}) - {self.response_status}'