diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..1b15671f992c97fdcbe7d00f945422545954295e --- /dev/null +++ b/.env @@ -0,0 +1,22 @@ +# 数据库连接信息 +DATABASE_URL="mysql+pymysql://root:mysql00@localhost:3306/xxxx" +#DATABASE_URL="mysql+pymysql://root:root%40000@101.126.146.60:33066/xxxx" + +# 用于JWT签名的密钥 +SECRET_KEY="your-secret-key-here" + +# 算法 +ALGORITHM="HS256" + +# 访问令牌过期时间(分钟) +ACCESS_TOKEN_EXPIRE_MINUTES=600 + +# Cloudflare R2 配置 +R2_ACCOUNT_ID="efcdfff6eb9a02a52320c089f89bcd39" +R2_ACCESS_KEY_ID="5a43e2f19b371745dc68f8a3e939fe40" +R2_SECRET_ACCESS_KEY="cf6421cc77954373a34b65925d34c9234e49679d8d31bc46a1404862af5c308e" +R2_BUCKET_NAME="yomaton" +R2_PUBLIC_URL="https://img.yomaton.com" + +# 是否启用 R2 上传(true/false) +R2_ENABLED=true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..2edef8f7a1c9a3e2d553ab47d3665a7131c0729a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# 基础镜像:python 3.11-slim(你的项目唯一稳定版本) +FROM python:3.11-slim + +# 创建用户(和示例保持一致) +RUN useradd -m -u 1000 user +USER user +ENV PATH="/home/user/.local/bin:$PATH" + +# 工作目录 +WORKDIR /app + +# 复制依赖清单 +COPY --chown=user ./requirements.txt requirements.txt + +# 安装依赖 + 额外缺失包(pillow, email-validator, pydantic[email]) +RUN pip install --no-cache-dir pillow email-validator pydantic[email] -i https://mirrors.aliyun.com/pypi/simple/ \ + && pip install --no-cache-dir --upgrade -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ + +# 复制全部项目代码 +COPY --chown=user . /app + +# 启动命令(你的命令:main.py runserver 8000) +CMD ["python", "main.py", "runserver", "0.0.0.0:8000"] \ No newline at end of file diff --git a/__pycache__/database.cpython-314.pyc b/__pycache__/database.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7bc3a51b7ca72dd403cb8ab645eb06de00fae722 Binary files /dev/null and b/__pycache__/database.cpython-314.pyc differ diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8bbb60ae200b546aaf1c4a24bb657173c8cadb20 Binary files /dev/null and b/__pycache__/main.cpython-314.pyc differ diff --git a/config/__pycache__/default_companies.cpython-314.pyc b/config/__pycache__/default_companies.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22efc6d178bc5e2ddb2862ecec9bf881cdc993ad Binary files /dev/null and b/config/__pycache__/default_companies.cpython-314.pyc differ diff --git a/config/__pycache__/member_levels.cpython-314.pyc b/config/__pycache__/member_levels.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c81726652a3c8aee92cb7a9f3de4d1b5bb633f2e Binary files /dev/null and b/config/__pycache__/member_levels.cpython-314.pyc differ diff --git a/config/__pycache__/templates.cpython-314.pyc b/config/__pycache__/templates.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ec4f2f8341df57f7e032c00e83664cd7a8963ad Binary files /dev/null and b/config/__pycache__/templates.cpython-314.pyc differ diff --git a/config/certification_types.py b/config/certification_types.py new file mode 100644 index 0000000000000000000000000000000000000000..82acef899a38918db649a5ccb8961b026a9f0ee5 --- /dev/null +++ b/config/certification_types.py @@ -0,0 +1,35 @@ +# 商品认证类型配置文件 +# 包含主流外贸产品常用的认证类型 + +DEFAULT_CERTIFICATION_TYPES = [ + { + "value": "CE", + "label": "CE", + "description": "欧盟强制性安全认证" + }, + { + "value": "FDA", + "label": "FDA", + "description": "美国食品药品监督管理局认证" + }, + { + "value": "RoHS", + "label": "RoHS", + "description": "欧盟环保认证" + }, + { + "value": "FCC", + "label": "FCC", + "description": "美国联邦通信委员会认证" + }, + { + "value": "ISO", + "label": "ISO", + "description": "国际标准化组织认证" + }, + { + "value": "OTHER", + "label": "OTHER", + "description": "其他认证类型" + } +] diff --git a/config/default_companies.py b/config/default_companies.py new file mode 100644 index 0000000000000000000000000000000000000000..62dfb83c3a028c8e7c951721f074061dce876e2b --- /dev/null +++ b/config/default_companies.py @@ -0,0 +1,22 @@ +# 默认会员公司数据 +DEFAULT_COMPANIES = [ + { + "company_code": "ymt0", + "name": "云贸通测试公司", + "description": "云贸通智能科技测试公司", + "phone": "13800138000", + "email": "test@ymt.com", + "address": "北京市朝阳区" + } +] + +# 默认会员账号数据 +DEFAULT_USERS = [ + { + "username": "ymt0", + "email": "ymt0@ymt.com", + "password": "ymt123456", # 原始密码 + "is_superadmin": False, + "company_code": "ymt0" + } +] diff --git a/config/member_levels.py b/config/member_levels.py new file mode 100644 index 0000000000000000000000000000000000000000..2bf9ca1e15454ccb55fa11659c39eab5f61de7cf --- /dev/null +++ b/config/member_levels.py @@ -0,0 +1,77 @@ +# 会员等级配置文件 +# 包含不同会员等级的权益配置 + +DEFAULT_MEMBER_LEVELS = [ + { + "level": 0, + "name": "普通会员", + "description": "普通会员", + "annual_fee": 5980, + "discount_price": 5980, + "deployment_nodes": 1, + "email_marketing_limit": 10000, + "product_sku_limit": 100, + "sku_image_limit": 10, + "image_size_limit": 5, + "sku_video_limit": 1, + "image_storage_limit": 10, + "template_access": False, + "geo_seo_access": False, + "social_automation_access": False, + "custom_development_access": False + }, + { + "level": 1, + "name": "金卡会员", + "description": "金卡会员", + "annual_fee": 9680, + "discount_price": 9680, + "deployment_nodes": 3, + "email_marketing_limit": 30000, + "product_sku_limit": 1000, + "sku_image_limit": 10, + "image_size_limit": 5, + "sku_video_limit": 1, + "image_storage_limit": 10, + "template_access":True, + "geo_seo_access": True, + "social_automation_access": False, + "custom_development_access": False + }, + { + "level": 2, + "name": "钻石会员", + "description": "钻石会员", + "annual_fee": 16800, + "discount_price": 16800, + "deployment_nodes": 6, + "email_marketing_limit": 30000, + "product_sku_limit": 10000, + "sku_image_limit": 10, + "image_size_limit": 5, + "sku_video_limit": 1, + "image_storage_limit": 10, + "template_access": True, + "geo_seo_access": True, + "social_automation_access": True, + "custom_development_access": False + }, + { + "level": 3, + "name": "皇冠会员", + "description": "定制化会员", + "annual_fee": 26800, + "discount_price": 26800, + "deployment_nodes": 6, + "email_marketing_limit": 50000, + "product_sku_limit": 10000, + "sku_image_limit": 10, + "image_size_limit": 5, + "sku_video_limit": 1, + "image_storage_limit": 10, + "template_access": True, + "geo_seo_access": True, + "social_automation_access": True, + "custom_development_access": True + } +] \ No newline at end of file diff --git a/config/templates.py b/config/templates.py new file mode 100644 index 0000000000000000000000000000000000000000..9e660af129a4738edfb994b379bc794004e36882 --- /dev/null +++ b/config/templates.py @@ -0,0 +1,239 @@ +# 模板配置文件 +# 包含20+主流外贸独立站模板风格 + +DEFAULT_TEMPLATES = [ + { + "key": "Home", + "name": "通用外贸", + "description": "适合大多数外贸行业的通用模板", + "color": "#3498db", + "features": ["响应式设计", "多语言支持", "产品展示"], + "applicable_scene": "通用外贸行业", + "regions": ["全球"] + }, + { + "key": "Electronics", + "name": "电子产品", + "description": "专为电子产品行业设计的模板", + "color": "#27ae60", + "features": ["产品对比", "技术规格", "客户评价"], + "applicable_scene": "电子产品、数码设备", + "regions": ["欧美", "全球"] + }, + { + "key": "Fashion", + "name": "时尚服装", + "description": "专为时尚服装行业设计的模板", + "color": "#e74c3c", + "features": ["图片轮播", "搭配推荐", "季节趋势"], + "applicable_scene": "服装、配饰、时尚产品", + "regions": ["欧美", "全球"] + }, + { + "key": "Furniture", + "name": "家居家具", + "description": "专为家居家具行业设计的模板", + "color": "#f39c12", + "features": ["3D展示", "空间搭配", "材质说明"], + "applicable_scene": "家具、家居用品、装饰品", + "regions": ["全球"] + }, + { + "key": "Robotics", + "name": "机器人", + "description": "专为机器人行业设计的模板", + "color": "#9b59b6", + "features": ["技术参数", "应用场景", "视频展示"], + "applicable_scene": "工业机器人、服务机器人、智能设备", + "regions": ["欧美", "全球"] + }, + { + "key": "Medical", + "name": "医疗器械", + "description": "专为医疗器械行业设计的模板", + "color": "#1abc9c", + "features": ["产品认证", "技术文档", "案例展示"], + "applicable_scene": "医疗设备、健康产品、医疗器械", + "regions": ["欧美", "全球"] + }, + { + "key": "Automotive", + "name": "汽车配件", + "description": "专为汽车配件行业设计的模板", + "color": "#e67e22", + "features": ["适配车型", "安装指南", "质量认证"], + "applicable_scene": "汽车零部件、汽车用品、改装配件", + "regions": ["欧美", "全球"] + }, + { + "key": "Beauty", + "name": "美容美妆", + "description": "专为美容美妆行业设计的模板", + "color": "#f1948a", + "features": ["产品功效", "使用方法", "用户评价"], + "applicable_scene": "化妆品、护肤品、美容工具", + "regions": ["全球"] + }, + { + "key": "Sports", + "name": "运动户外", + "description": "专为运动户外行业设计的模板", + "color": "#e84118", + "features": ["产品参数", "使用场景", "用户体验"], + "applicable_scene": "运动器材、户外装备、健身用品", + "regions": ["全球"] + }, + { + "key": "Toys", + "name": "玩具礼品", + "description": "专为玩具礼品行业设计的模板", + "color": "#e1b12c", + "features": ["年龄适用", "安全认证", "创意展示"], + "applicable_scene": "儿童玩具、礼品、益智产品", + "regions": ["全球"] + }, + { + "key": "Jewelry", + "name": "珠宝首饰", + "description": "专为珠宝首饰行业设计的模板", + "color": "#8e44ad", + "features": ["材质说明", "工艺展示", "证书认证"], + "applicable_scene": "珠宝、首饰、配饰", + "regions": ["全球"] + }, + { + "key": "Food", + "name": "食品饮料", + "description": "专为食品饮料行业设计的模板", + "color": "#c0392b", + "features": ["营养成分", "生产工艺", "品尝体验"], + "applicable_scene": "食品、饮料、保健品", + "regions": ["全球"] + }, + { + "key": "Pet", + "name": "宠物用品", + "description": "专为宠物用品行业设计的模板", + "color": "#16a085", + "features": ["适用宠物", "产品功能", "用户反馈"], + "applicable_scene": "宠物食品、宠物用品、宠物玩具", + "regions": ["欧美", "全球"] + }, + { + "key": "Office", + "name": "办公文具", + "description": "专为办公文具行业设计的模板", + "color": "#34495e", + "features": ["产品规格", "适用场景", "批量采购"], + "applicable_scene": "办公用品、文具、办公设备", + "regions": ["全球"] + }, + { + "key": "Building", + "name": "建筑建材", + "description": "专为建筑建材行业设计的模板", + "color": "#7f8c8d", + "features": ["材料规格", "施工指南", "质量认证"], + "applicable_scene": "建筑材料、装饰材料、五金工具", + "regions": ["全球"] + }, + { + "key": "Packaging", + "name": "包装印刷", + "description": "专为包装印刷行业设计的模板", + "color": "#2c3e50", + "features": ["定制服务", "印刷工艺", "样品展示"], + "applicable_scene": "包装材料、印刷制品、定制包装", + "regions": ["全球"] + }, + { + "key": "Energy", + "name": "能源环保", + "description": "专为能源环保行业设计的模板", + "color": "#22a7f0", + "features": ["节能效果", "环保认证", "技术参数"], + "applicable_scene": "太阳能产品、节能设备、环保材料", + "regions": ["欧美", "全球"] + }, + { + "key": "Textile", + "name": "纺织面料", + "description": "专为纺织面料行业设计的模板", + "color": "#ec407a", + "features": ["面料材质", "规格参数", "应用领域"], + "applicable_scene": "纺织品、面料、服装辅料", + "regions": ["全球"] + }, + { + "key": "Hardware", + "name": "五金工具", + "description": "专为五金工具行业设计的模板", + "color": "#7d5fff", + "features": ["工具规格", "使用方法", "质量保证"], + "applicable_scene": "五金工具、电动工具、手动工具", + "regions": ["全球"] + }, + { + "key": "Chemicals", + "name": "化工产品", + "description": "专为化工产品行业设计的模板", + "color": "#00b894", + "features": ["产品规格", "安全说明", "应用领域"], + "applicable_scene": "化工原料、化学制品、工业化学品", + "regions": ["全球"] + }, + { + "key": "Agriculture", + "name": "农业产品", + "description": "专为农业产品行业设计的模板", + "color": "#6ab04c", + "features": ["产品规格", "种植技术", "质量认证"], + "applicable_scene": "农产品、农业设备、农业用品", + "regions": ["全球"] + }, + { + "key": "SoutheastAsia", + "name": "东南亚风格", + "description": "专为东南亚市场设计的模板,融入当地文化元素", + "color": "#ff6b6b", + "features": ["热带风格", "本地化语言", "移动优先"], + "applicable_scene": "面向东南亚市场的各类产品", + "regions": ["东南亚"] + }, + { + "key": "MiddleEast", + "name": "中东风格", + "description": "专为中东市场设计的模板,考虑当地文化和宗教因素", + "color": "#feca57", + "features": ["符合当地文化", "多语言支持", "尊贵感设计"], + "applicable_scene": "面向中东市场的各类产品", + "regions": ["中东"] + }, + { + "key": "European", + "name": "欧洲风格", + "description": "专为欧洲市场设计的模板,体现欧洲美学和品味", + "color": "#54a0ff", + "features": ["简约优雅", "高质量视觉", "环保理念"], + "applicable_scene": "面向欧洲市场的各类产品", + "regions": ["欧洲"] + }, + { + "key": "American", + "name": "美国风格", + "description": "专为美国市场设计的模板,体现美国消费者偏好", + "color": "#48dbfb", + "features": ["大胆设计", "清晰信息", "快速加载"], + "applicable_scene": "面向美国市场的各类产品", + "regions": ["美洲"] + }, + { + "key": "EastAsia", + "name": "东亚风格", + "description": "专为东亚市场设计的模板,融入东亚文化元素", + "color": "#ff9ff3", + "features": ["精致设计", "细腻体验", "社交分享"], + "applicable_scene": "面向东亚市场的各类产品", + "regions": ["东亚"] + } +] \ No newline at end of file diff --git a/create_tables.py b/create_tables.py new file mode 100644 index 0000000000000000000000000000000000000000..aab49b2760fa28c1468b4ea0dc683580edacdaf8 --- /dev/null +++ b/create_tables.py @@ -0,0 +1,23 @@ +from database import engine, Base +from models.product import Product +from models.company import Company +from models.user import User +from models.category import Category +from models.membership import Membership +from models.operation_log import OperationLog +from models.contact import Contact +from models.template import Template +from models.member_level import MemberLevel +from models.customer_relationship import CustomerRelationship + +print("=" * 80) +print("开始创建数据库表...") +print("=" * 80) + +# 创建所有表 +Base.metadata.create_all(bind=engine) + +print() +print("=" * 80) +print("所有表创建完成!") +print("=" * 80) diff --git a/database.py b/database.py new file mode 100644 index 0000000000000000000000000000000000000000..847cff4d1eba7d81c8bccf78bcc72b00bc468a7a --- /dev/null +++ b/database.py @@ -0,0 +1,111 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from dotenv import load_dotenv +import os +import pymysql + +# 加载环境变量 +load_dotenv() + +# 获取数据库连接 URL +DATABASE_URL = os.getenv("DATABASE_URL") + +# 处理MySQL数据库自动创建 +if DATABASE_URL.startswith("mysql"): + # 解析数据库连接信息 + import re + match = re.match(r"mysql\+pymysql://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)", DATABASE_URL) + if match: + user, password, host, port, db_name = match.groups() + port = int(port) + + # 先连接到MySQL服务器 + try: + # 连接到MySQL服务器(不指定数据库) + conn = pymysql.connect( + host=host, + port=port, + user=user, + password=password, + charset='utf8mb4' + ) + cursor = conn.cursor() + + # 检查数据库是否存在 + cursor.execute(f"SHOW DATABASES LIKE '{db_name}'") + result = cursor.fetchone() + + # 如果数据库不存在,创建它 + if not result: + cursor.execute(f"CREATE DATABASE {db_name} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + print(f"数据库 {db_name} 已创建") + + cursor.close() + conn.close() + except Exception as e: + print(f"创建数据库时出错: {e}") + +# 创建数据库引擎 +# 对于SQLite,需要添加check_same_thread参数 +if DATABASE_URL.startswith("sqlite"): + engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +else: + # 对于MySQL,添加charset参数确保utf8编码 + if DATABASE_URL.startswith("mysql"): + engine = create_engine(DATABASE_URL + "?charset=utf8mb4") + else: + engine = create_engine(DATABASE_URL) + +# 创建会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 创建基类 +Base = declarative_base() + +# 检查表结构并自动更新 +def check_and_update_tables(): + """检查表结构是否与models一致,不一致则自动更新""" + try: + # 导入所有模型,确保它们被注册到 Base + from models.product import Product + from models.company import Company + from models.user import User + from models.category import Category + from models.membership import Membership + from models.operation_log import OperationLog + from models.contact import Contact + from models.template import Template + from models.member_level import MemberLevel + from models.customer_relationship import CustomerRelationship + from models.product_certification import ProductCertification + from models.product_media import ProductMedia + from models.product_packaging import ProductPackaging + from models.company_r2_config import CompanyR2Config + from models.media import Media, MediaTag, MediaTagMapping, MediaDirectory + + # 检查数据库连接 + with engine.connect() as conn: + print("数据库连接成功") + + # 自动创建不存在的表 + Base.metadata.create_all(bind=engine) + print("表结构检查完成,不存在的表已创建") + + # 注意:SQLAlchemy的create_all()只会创建不存在的表,不会更新已存在表的结构 + # 对于表结构的更新,建议使用Alembic迁移工具 + # print("提示:已存在表的结构更新需要使用Alembic迁移工具") + + except Exception as e: + print(f"检查表结构时出错: {e}") + +# 调用检查表结构的函数 +check_and_update_tables() + +# 依赖项,用于获取数据库会话 +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..4a470a6ae9591d66f91b1d5bb1d1a65789dbd904 --- /dev/null +++ b/main.py @@ -0,0 +1,181 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from routers import auth, company, categories, products, product_certifications, product_media, product_packaging, membership, operation_log, contact, template, customer_relationship, member_level, r2_config, media, statistics +from database import engine, Base, get_db +from sqlalchemy.orm import Session +import json +import os +from pathlib import Path +from dotenv import load_dotenv +from datetime import datetime, timedelta + +# 导入中间件 +from middleware.trace import TraceMiddleware + +# 导入日志模块 +from utils.logger import logger + +# 加载 .env 文件 +load_dotenv() + +# 导入模板配置 +from config.templates import DEFAULT_TEMPLATES +# 导入会员等级配置 +from config.member_levels import DEFAULT_MEMBER_LEVELS +# 导入默认公司和用户配置 +from config.default_companies import DEFAULT_COMPANIES, DEFAULT_USERS + +# 导入所有模型,确保它们被注册到Base +from models.user import User +from models.company import Company +from models.category import Category +from models.product import Product +from models.membership import Membership +from models.operation_log import OperationLog +from models.contact import Contact +from models.template import Template +from models.customer_relationship import CustomerRelationship +from models.member_level import MemberLevel +from models.company_r2_config import CompanyR2Config +from models.media import Media, MediaTag, MediaTagMapping, MediaDirectory +from models.site_visit import SiteVisit +from utils.auth import get_password_hash + +# 数据库表创建已在database.py中处理 + +app = FastAPI( + title="外贸独立站 API", + description="用于外贸独立站的后端 API", + version="1.0.0" +) + +# 配置 CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 在生产环境中应该设置具体的前端域名 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 添加 TraceMiddleware 中间件 +app.add_middleware(TraceMiddleware) + +# 挂载静态文件目录(用于访问上传的图片) +BASE_DIR = Path(__file__).parent +IMAGES_DIR = BASE_DIR / "images" +if IMAGES_DIR.exists(): + app.mount("/images", StaticFiles(directory=str(IMAGES_DIR)), name="images") + +# 注册路由 +# 注意:更具体的路由(如 /{product_number}/media)必须在较不具体的路由(如 /{product_number})之前注册 +app.include_router(auth.router, prefix="/api/auth", tags=["认证"]) +app.include_router(product_media.router, prefix="/api/products", tags=["产品媒体"]) +app.include_router(product_certifications.router, prefix="/api/products", tags=["产品认证"]) +app.include_router(product_packaging.router, prefix="/api/products", tags=["产品包装"]) +app.include_router(products.router, prefix="/api/products", tags=["产品"]) +app.include_router(company.router, prefix="/api/company", tags=["公司信息"]) +app.include_router(categories.router, prefix="/api/categories", tags=["类目"]) +app.include_router(membership.router, prefix="/api/membership", tags=["会员充值"]) +app.include_router(operation_log.router, prefix="/api/operation-logs", tags=["操作日志"]) +app.include_router(contact.router, prefix="/api/contact", tags=["联系信息管理"]) +app.include_router(template.router, prefix="/api/templates", tags=["模板管理"]) +app.include_router(customer_relationship.router, prefix="/api/customer-relationships", tags=["客户关系管理"]) +app.include_router(member_level.router, prefix="/api/member-levels", tags=["会员等级管理"]) +app.include_router(r2_config.router, prefix="/api/r2-config", tags=["R2 配置管理"]) +app.include_router(media.router, prefix="/api/media", tags=["媒体素材管理"]) +app.include_router(statistics.router, prefix="/api/statistics", tags=["统计指标"]) + +# 初始化超级管理员用户和默认类目 +@app.on_event("startup") +def startup_event(): + try: + db = next(get_db()) + # 检查是否已有超级管理员 + superadmin = db.query(User).filter(User.is_superadmin == True).first() + if not superadmin: + # 创建超级管理员 + superadmin = User( + username="admin", + email="admin@mail.yomaton.com", + password=get_password_hash("admin00"), + is_superadmin=True, + company_code="0000" + ) + db.add(superadmin) + db.commit() + logger.info("超级管理员已创建: username=admin, password=admin00") + + + # 创建默认模板 + for template_data in DEFAULT_TEMPLATES: + # 转换features和regions列表为JSON字符串 + template_data["features"] = json.dumps(template_data["features"], ensure_ascii=False) + template_data["regions"] = json.dumps(template_data["regions"], ensure_ascii=False) + + existing_template = db.query(Template).filter(Template.key == template_data["key"]).first() + if not existing_template: + new_template = Template(**template_data) + db.add(new_template) + db.commit() + logger.info("默认模板已创建") + + # 创建默认会员等级 + for level_data in DEFAULT_MEMBER_LEVELS: + existing_level = db.query(MemberLevel).filter(MemberLevel.level == level_data["level"]).first() + if not existing_level: + new_level = MemberLevel(**level_data) + db.add(new_level) + db.commit() + logger.info("默认会员等级已创建") + + # 创建默认公司 + for company_data in DEFAULT_COMPANIES: + existing_company = db.query(Company).filter(Company.company_code == company_data["company_code"]).first() + if not existing_company: + new_company = Company(**company_data) + db.add(new_company) + db.commit() + logger.info("默认公司已创建") + + # 创建默认用户和会员信息 + for user_data in DEFAULT_USERS: + existing_user = db.query(User).filter(User.username == user_data["username"]).first() + if not existing_user: + user_data_copy = user_data.copy() + user_data_copy["password"] = get_password_hash(user_data_copy["password"]) + new_user = User(**user_data_copy) + db.add(new_user) + db.flush() + + # 为用户创建默认会员信息 + existing_membership = db.query(Membership).filter(Membership.company_code == user_data["company_code"]).first() + if not existing_membership: + new_membership = Membership( + company_code = user_data["company_code"], + level = 0, # 默认普通会员 + start_date = datetime.now(), + end_date = datetime.now() + timedelta(days=365) + ) + db.add(new_membership) + db.commit() + logger.info("默认用户和会员信息已创建") + except Exception as e: + logger.error(f"初始化数据时出错: {e}") + +@app.get("/") +def read_root(): + logger.info("访问根路径") + return {"message": "欢迎使用外贸独立站 API"} + +@app.get("/health") +def health_check(): + logger.info("健康检查") + return {"status": "healthy"} + +if __name__ == "__main__": + import uvicorn + logger.info("启动应用服务器") + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/middleware/trace.py b/middleware/trace.py new file mode 100644 index 0000000000000000000000000000000000000000..b1c10a130d578beb2eda85da5a19a7a013ec0c18 --- /dev/null +++ b/middleware/trace.py @@ -0,0 +1,29 @@ +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +import uuid +from contextvars import ContextVar + +# 创建一个上下文变量来存储 trace_id +trace_id_var = ContextVar('trace_id', default=None) + +class TraceMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + # 生成唯一的 trace_id + trace_id = str(uuid.uuid4()) + + # 设置上下文变量 + token = trace_id_var.set(trace_id) + + try: + # 处理请求 + response = await call_next(request) + # 将 trace_id 添加到响应头中 + response.headers['X-Trace-ID'] = trace_id + return response + finally: + # 清理上下文变量 + trace_id_var.reset(token) + +def get_trace_id(): + """获取当前请求的 trace_id""" + return trace_id_var.get() diff --git a/models/__pycache__/category.cpython-314.pyc b/models/__pycache__/category.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d154229b612d3542ef2999243b67b26a4bc8c926 Binary files /dev/null and b/models/__pycache__/category.cpython-314.pyc differ diff --git a/models/__pycache__/company.cpython-314.pyc b/models/__pycache__/company.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c27c1967ecaeb7dfe8f70e528952672bebdf200 Binary files /dev/null and b/models/__pycache__/company.cpython-314.pyc differ diff --git a/models/__pycache__/company_r2_config.cpython-314.pyc b/models/__pycache__/company_r2_config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f910c5f2ab00278a0ba2ec1b4a3b7bbd306b5368 Binary files /dev/null and b/models/__pycache__/company_r2_config.cpython-314.pyc differ diff --git a/models/__pycache__/contact.cpython-314.pyc b/models/__pycache__/contact.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..928bab2e502e61172c6d1eef8b71ba483e9efe98 Binary files /dev/null and b/models/__pycache__/contact.cpython-314.pyc differ diff --git a/models/__pycache__/customer_relationship.cpython-314.pyc b/models/__pycache__/customer_relationship.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0468a5caeda3cb6f7e0ac91ea528f92a41ace85d Binary files /dev/null and b/models/__pycache__/customer_relationship.cpython-314.pyc differ diff --git a/models/__pycache__/media.cpython-314.pyc b/models/__pycache__/media.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6481b5fa66164725262bddf8915890557072000 Binary files /dev/null and b/models/__pycache__/media.cpython-314.pyc differ diff --git a/models/__pycache__/member_level.cpython-314.pyc b/models/__pycache__/member_level.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d9d4a03d299703f8d68017e92ab75af31c958963 Binary files /dev/null and b/models/__pycache__/member_level.cpython-314.pyc differ diff --git a/models/__pycache__/membership.cpython-314.pyc b/models/__pycache__/membership.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a16a35952253eac324ebdd7bbc4b5ba479171779 Binary files /dev/null and b/models/__pycache__/membership.cpython-314.pyc differ diff --git a/models/__pycache__/operation_log.cpython-314.pyc b/models/__pycache__/operation_log.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3ce27a6796d8c3b762eb33cf622eb87513ac9b5 Binary files /dev/null and b/models/__pycache__/operation_log.cpython-314.pyc differ diff --git a/models/__pycache__/product.cpython-314.pyc b/models/__pycache__/product.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7fcaeaa337242bd15ad8de94ffa20cb7172e9e54 Binary files /dev/null and b/models/__pycache__/product.cpython-314.pyc differ diff --git a/models/__pycache__/product_certification.cpython-314.pyc b/models/__pycache__/product_certification.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4a7dddb33808ea78560584c55994a269f9246d57 Binary files /dev/null and b/models/__pycache__/product_certification.cpython-314.pyc differ diff --git a/models/__pycache__/product_media.cpython-314.pyc b/models/__pycache__/product_media.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe5b318ff2b9f38ef24439a9b5b0c45132f6ac9a Binary files /dev/null and b/models/__pycache__/product_media.cpython-314.pyc differ diff --git a/models/__pycache__/product_packaging.cpython-314.pyc b/models/__pycache__/product_packaging.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f149ae8b0303a29e091714d8c5e6537fcbde4e42 Binary files /dev/null and b/models/__pycache__/product_packaging.cpython-314.pyc differ diff --git a/models/__pycache__/template.cpython-314.pyc b/models/__pycache__/template.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1145eae556d01999c24867ffa4651685a5771d13 Binary files /dev/null and b/models/__pycache__/template.cpython-314.pyc differ diff --git a/models/__pycache__/user.cpython-314.pyc b/models/__pycache__/user.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61b18f4e08b02430543f3f0cda3c7de8729fc2eb Binary files /dev/null and b/models/__pycache__/user.cpython-314.pyc differ diff --git a/models/category.py b/models/category.py new file mode 100644 index 0000000000000000000000000000000000000000..d014146b563142643abb53e240b9573d213b0d53 --- /dev/null +++ b/models/category.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, DateTime, text +from sqlalchemy.orm import relationship +from database import Base + +class Category(Base): + __tablename__ = "categories" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + parent_code = Column(String(100), nullable=False, server_default="", index=True, comment='父分类编码,空为顶级分类') + code = Column(String(100), nullable=False, server_default="", index=True, comment='分类编码,级联编码格式') + name = Column(String(100), nullable=False, server_default="", index=True, comment='分类名称') + sort_order = Column(Integer, nullable=False, server_default="0", comment='排序顺序') + status = Column(Integer, nullable=False, server_default="1", comment='状态:0-禁用,1-启用') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码,用于数据隔离') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') + + # 关系 + products = relationship("Product", back_populates="category") diff --git a/models/company.py b/models/company.py new file mode 100644 index 0000000000000000000000000000000000000000..f31674fcbc45a69a4e4a93b8d17c4f6630b29505 --- /dev/null +++ b/models/company.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime +from sqlalchemy.sql import text +from database import Base + +class Company(Base): + __tablename__ = "companies" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + company_code = Column(String(50), nullable=False, server_default="", unique=True, index=True, comment='公司编码') + name = Column(String(100), nullable=False, server_default="", comment='公司名称') + logo_url = Column(String(255), nullable=False, server_default="", comment='公司logo') + description = Column(Text, nullable=False, default="", comment='公司描述') + address = Column(Text, nullable=False, default="", comment='公司地址') + phone = Column(String(50), nullable=False, server_default="", comment='联系电话') + email = Column(String(100), nullable=False, server_default="", comment='联系邮箱') + website = Column(String(255), nullable=False, server_default="", comment='公司网站') + business_license = Column(String(255), nullable=False, server_default="", comment='营业执照') + established_date = Column(DateTime, nullable=True, comment='成立日期') + employees_count = Column(Integer, nullable=False, server_default="0", comment='员工数量') + business_scope = Column(Text, nullable=False, default="", comment='经营范围') + level = Column(Integer, nullable=False, server_default="0", comment='会员等级:0-普通会员,1-金卡,2-钻石,3-皇冠') + template = Column(String(50), nullable=False, server_default="Home", comment='模板选择') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/models/company_r2_config.py b/models/company_r2_config.py new file mode 100644 index 0000000000000000000000000000000000000000..ac6a3f6ca74d73a44da89420598670ea3cdf35aa --- /dev/null +++ b/models/company_r2_config.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.sql import text +from database import Base + +class CompanyR2Config(Base): + __tablename__ = "company_r2_configs" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + company_code = Column(String(50), nullable=False, unique=True, index=True, comment='公司编码,唯一') + r2_account_id = Column(String(200), nullable=False, server_default="", comment='R2 账户 ID') + r2_access_key_id = Column(String(200), nullable=False, server_default="", comment='R2 访问密钥 ID') + r2_secret_access_key = Column(String(200), nullable=False, server_default="", comment='R2 密钥') + r2_bucket_name = Column(String(200), nullable=False, server_default="", comment='R2 桶名称') + r2_public_url = Column(String(500), nullable=False, server_default="", comment='R2 公共访问 URL') + r2_enabled = Column(Integer, nullable=False, server_default="1", comment='是否启用 R2:0-禁用,1-启用') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/models/contact.py b/models/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..c48ddb8a9630bc2b4d67da0c181fa9cb9bb128be --- /dev/null +++ b/models/contact.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime +from sqlalchemy.sql import text +from database import Base + +class Contact(Base): + __tablename__ = "contacts" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + company_code = Column(String(50), ForeignKey("companies.company_code"), nullable=False, server_default="", index=True, comment='公司编码') + contact_name = Column(String(100), nullable=False, server_default="", comment='联系人姓名') + phone = Column(String(50), nullable=False, server_default="", comment='联系电话') + email = Column(String(100), nullable=False, server_default="", comment='电子邮箱') + position = Column(String(100), nullable=False, server_default="", comment='职位') + department = Column(String(100), nullable=False, server_default="", comment='部门') + facebook = Column(String(255), nullable=False, server_default="", comment='Facebook账号') + facebook_api_key = Column(String(255), nullable=False, server_default="", comment='Facebook API Key') + facebook_app_secret = Column(String(255), nullable=False, server_default="", comment='Facebook App Secret') + tiktok = Column(String(255), nullable=False, server_default="", comment='TikTok账号') + tiktok_api_key = Column(String(255), nullable=False, server_default="", comment='TikTok API Key') + tiktok_app_secret = Column(String(255), nullable=False, server_default="", comment='TikTok App Secret') + whatsapp = Column(String(50), nullable=False, server_default="", comment='WhatsApp账号') + whatsapp_api_key = Column(String(255), nullable=False, server_default="", comment='WhatsApp API Key') + whatsapp_app_secret = Column(String(255), nullable=False, server_default="", comment='WhatsApp App Secret') + wechat = Column(String(100), nullable=False, server_default="", comment='微信账号') + linkedin = Column(String(255), nullable=False, server_default="", comment='LinkedIn账号') + note = Column(Text, nullable=False, default="", comment='备注') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/models/customer_relationship.py b/models/customer_relationship.py new file mode 100644 index 0000000000000000000000000000000000000000..094b2e3578d77498abbeb35cdf49c1f8a87f95ba --- /dev/null +++ b/models/customer_relationship.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.sql import text +from database import Base + +class CustomerRelationship(Base): + __tablename__ = "customer_relationships" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码') + customer_name = Column(String(100), nullable=False, server_default="", comment='客户名称') + contact_person = Column(String(100), nullable=False, server_default="", comment='联系人') + phone = Column(String(50), nullable=False, server_default="", comment='电话') + email = Column(String(100), nullable=False, server_default="", comment='邮箱') + social_media = Column(Text, nullable=False, default="", comment='社交平台账号,JSON格式') + address = Column(Text, nullable=False, default="", comment='地址') + notes = Column(Text, nullable=False, default="", comment='备注') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/models/media.py b/models/media.py new file mode 100644 index 0000000000000000000000000000000000000000..474dde26c3b7a01690477428c10b4e944d1e880b --- /dev/null +++ b/models/media.py @@ -0,0 +1,87 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Enum, Float, Boolean +from sqlalchemy.sql import text +from database import Base +import enum + + +class MediaStatus(enum.Enum): + PENDING = "pending" + UPLOADING = "uploading" + SUCCESS = "success" + FAILED = "failed" + + +class MediaTag(Base): + """素材标签表""" + __tablename__ = "media_tags" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + tag_name = Column(String(50), nullable=False, unique=True, index=True, comment='标签名称') + tag_color = Column(String(20), nullable=False, server_default="#3b82f6", comment='标签颜色(hex)') + sort_order = Column(Integer, nullable=False, server_default="0", comment='排序顺序') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') + + +class MediaTagMapping(Base): + """素材-标签关联表(多对多)""" + __tablename__ = "media_tag_mappings" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + media_id = Column(String(32), ForeignKey('media.media_id'), nullable=False, index=True, comment='素材ID') + tag_id = Column(Integer, ForeignKey('media_tags.id'), nullable=False, index=True, comment='标签ID') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + + +class Media(Base): + __tablename__ = "media" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + media_id = Column(String(32), nullable=False, unique=True, index=True, comment='素材唯一ID(32位UUID)') + file_name = Column(String(500), nullable=False, comment='原始文件名') + r2_key = Column(String(500), nullable=False, server_default="", comment='R2原图对象键(MD5命名)') + r2_url = Column(String(500), nullable=False, server_default="", comment='R2原图URL') + thumbnail_r2_key = Column(String(500), nullable=False, server_default="", comment='R2缩略图对象键') + thumbnail_r2_url = Column(String(500), nullable=False, server_default="", comment='R2缩略图URL') + media_type = Column(String(20), nullable=False, server_default="image", comment='媒体类型:image, video') + mime_type = Column(String(100), nullable=False, server_default="", comment='MIME类型') + file_size = Column(Integer, nullable=False, server_default="0", comment='文件大小(字节)') + width = Column(Integer, nullable=True, comment='图片宽度') + height = Column(Integer, nullable=True, comment='图片高度') + duration = Column(Float, nullable=True, comment='视频时长(秒)') + directory_id = Column(Integer, ForeignKey('media_directories.id'), nullable=True, comment='目录ID') + status = Column(Enum(MediaStatus), nullable=False, server_default="pending", comment='上传状态') + error_message = Column(Text, nullable=True, comment='错误信息') + batch_id = Column(String(32), nullable=True, index=True, comment='批次ID(批量上传时使用)') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码') + is_deleted = Column(Boolean, nullable=False, server_default="0", index=True, comment='是否已删除:0-未删除,1-已删除') + deleted_at = Column(DateTime(timezone=True), nullable=True, comment='删除时间') + + # 关联关系 + tags = Column(JSON, nullable=True, comment='标签ID列表(JSON数组)') + + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') + + + +class MediaDirectory(Base): + __tablename__ = "media_directories" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + name = Column(String(100), nullable=False, comment='目录名称') + parent_id = Column(Integer, ForeignKey('media_directories.id'), nullable=True, comment='父目录ID') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码') + path = Column(String(500), nullable=False, server_default="", comment='完整路径') + level = Column(Integer, nullable=False, server_default="0", comment='目录层级') + sort_order = Column(Integer, nullable=False, server_default="0", comment='排序顺序') + cover_r2_key = Column(String(500), nullable=True, comment='目录封面图R2键') + cover_r2_url = Column(String(500), nullable=True, comment='目录封面图R2URL') + description = Column(Text, nullable=True, comment='目录描述') + is_public = Column(Boolean, nullable=False, server_default="0", comment='是否公开') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') + + # 关联关系 + media_count = Column(Integer, nullable=False, server_default="0", comment='素材数量(缓存字段)') diff --git a/models/member_level.py b/models/member_level.py new file mode 100644 index 0000000000000000000000000000000000000000..a4c3c5c86964424270904bd460438fc6266e16c7 --- /dev/null +++ b/models/member_level.py @@ -0,0 +1,26 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Float +from sqlalchemy.sql import text +from database import Base + +class MemberLevel(Base): + __tablename__ = "member_levels" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + level = Column(Integer, nullable=False, server_default="0", unique=True, index=True, comment='会员等级') + name = Column(String(50), nullable=False, server_default="", comment='等级名称') + description = Column(Text, nullable=False, default="", comment='等级描述') + annual_fee = Column(Float, nullable=False, server_default="0", comment='会员每年权益服务费') + discount_price = Column(Float, nullable=False, server_default="0", comment='当前优惠价') + deployment_nodes = Column(Integer, nullable=False, server_default="1", comment='独立站部署全球节点数') + email_marketing_limit = Column(Integer, nullable=False, server_default="10000", comment='每个月邮件营销推送数量') + product_sku_limit = Column(Integer, nullable=False, server_default="100", comment='产品SKU数') + sku_image_limit = Column(Integer, nullable=False, server_default="10", comment='每个SKU可加图片数量') + image_size_limit = Column(Integer, nullable=False, server_default="5", comment='图片大小限制(MB)') + sku_video_limit = Column(Integer, nullable=False, server_default="1", comment='每个SKU可加视频数量') + image_storage_limit = Column(Integer, nullable=False, server_default="5", comment='图片空间大小(G)') + template_access = Column(Boolean, nullable=False, server_default="0", comment='模板选择权限') + geo_seo_access = Column(Boolean, nullable=False, server_default="0", comment='GEO,SEO关键词营销') + social_automation_access = Column(Boolean, nullable=False, server_default="0", comment='社交平台自动化营销') + custom_development_access = Column(Boolean, nullable=False, server_default="0", comment='定制化需求开发') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/models/membership.py b/models/membership.py new file mode 100644 index 0000000000000000000000000000000000000000..8156dd99e086a2ebe779ea51121e75598c0ebdce --- /dev/null +++ b/models/membership.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.sql import func, text +from database import Base + +class Membership(Base): + __tablename__ = "membership" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码') + level = Column(Integer, nullable=False, server_default="0", comment='会员等级') + start_date = Column(DateTime, nullable=True, comment='有效期开始') + end_date = Column(DateTime, nullable=True, comment='有效期结束') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') \ No newline at end of file diff --git a/models/operation_log.py b/models/operation_log.py new file mode 100644 index 0000000000000000000000000000000000000000..523c878e0b0d57607d62156fb3043c65003b517d --- /dev/null +++ b/models/operation_log.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.sql import text +from database import Base + +class OperationLog(Base): + __tablename__ = "operation_logs" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + user_id = Column(Integer, nullable=False, server_default="0", comment='操作人ID') + username = Column(String(50), nullable=False, server_default="", comment='操作人用户名') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='操作所属公司编码') + module = Column(String(50), nullable=False, server_default="", comment='操作模块(如:user, company, product等)') + operation_type = Column(String(20), nullable=False, server_default="", comment='操作类型:add, edit, delete') + operation_detail = Column(Text, nullable=False, default="", comment='操作详情') + before_data = Column(Text, nullable=False, default="", comment='修改前的数据') + after_data = Column(Text, nullable=False, default="", comment='修改后的数据') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='操作时间') diff --git a/models/product.py b/models/product.py new file mode 100644 index 0000000000000000000000000000000000000000..08acd7b1f086ff6a47f08064df833b95a024c9a9 --- /dev/null +++ b/models/product.py @@ -0,0 +1,46 @@ +from sqlalchemy import Column, Integer, String, Float, Text, DateTime, JSON, ForeignKey +from sqlalchemy.sql import text +from sqlalchemy.orm import relationship +from database import Base + +class Product(Base): + __tablename__ = "products" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + category_id = Column(Integer, ForeignKey("categories.id"), nullable=False, server_default="0", index=True, comment='分类 ID') + product_number = Column(String(11), nullable=False, unique=True, index=True, comment='产品货号,11 位 UUID') + model = Column(String(50), nullable=False, server_default="", comment='产品型号,业务输入') + name = Column(String(200), nullable=False, server_default="", index=True, comment='产品名称') + brand = Column(String(100), nullable=False, server_default="", comment='品牌名称') + price = Column(Float, nullable=False, server_default="0", comment='价格') + original_price = Column(Float, nullable=False, server_default="0", comment='原价') + currency = Column(String(10), nullable=False, server_default="USD", comment='货币符号') + stock = Column(Integer, nullable=False, server_default="0", comment='库存') + min_order = Column(Integer, nullable=False, server_default="1", comment='最小起订量') + unit = Column(String(20), nullable=False, server_default="pcs", comment='计量单位') + status = Column(Integer, nullable=False, server_default="1", comment='状态:0-下架,1-上架') + is_new = Column(Integer, nullable=False, server_default="0", comment='是否新品:0-否,1-是') + is_hot = Column(Integer, nullable=False, server_default="0", comment='是否热销:0-否,1-是') + is_best_seller = Column(Integer, nullable=False, server_default="0", comment='是否畅销:0-否,1-是') + sort_order = Column(Integer, nullable=False, server_default="0", comment='排序顺序') + + # 商品详情字段 + description = Column(Text, nullable=False, comment='功能描述') + usage = Column(Text, nullable=False, comment='适用范围') + material = Column(String(200), nullable=False, server_default="", comment='材质') + parameters = Column(Text, nullable=False, comment='其他参数说明') + + # SEO 字段 + seo_title_tag = Column(String(200), nullable=False, server_default="", comment='SEO 页面标题') + seo_meta_keywords = Column(String(500), nullable=False, server_default="", comment='SEO 元关键词') + + # 产地字段 + country_of_origin = Column(String(100), nullable=False, server_default="", comment='原产国') + made_in_label = Column(String(100), nullable=False, server_default="", comment='Made in 标签') + + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码,用于数据隔离') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') + + # 关系 + category = relationship("Category", back_populates="products") diff --git a/models/product_certification.py b/models/product_certification.py new file mode 100644 index 0000000000000000000000000000000000000000..39a9dabe876f89c64a8f73d7ee16879aa9d66cd0 --- /dev/null +++ b/models/product_certification.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.sql import text +from database import Base + +class ProductCertification(Base): + __tablename__ = "product_certifications" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + product_id = Column(Integer, ForeignKey('products.id'), nullable=True, index=True, comment='产品 ID(外键)') + product_number = Column(String(11), nullable=False, server_default="", index=True, comment='产品货号') + certification_type = Column(String(50), nullable=False, server_default="", index=True, comment='认证类型:CE, FDA, RoHS, FCC, ISO, OTHER') + r2_url = Column(String(500), nullable=False, server_default="", comment='认证文件 URL(R2 URL)') + thumbnail_r2_url = Column(String(500), nullable=False, server_default="", comment='认证缩略图路径(R2 URL)') + media_id = Column(String(32), nullable=False, server_default="", comment='素材ID') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码,用于数据隔离') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/models/product_media.py b/models/product_media.py new file mode 100644 index 0000000000000000000000000000000000000000..5386db7207c437860f8b440a55d7d4f1ab751ea5 --- /dev/null +++ b/models/product_media.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.sql import text +from database import Base + +class ProductMedia(Base): + __tablename__ = "product_media" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + product_id = Column(Integer, ForeignKey('products.id'), nullable=True, index=True, comment='产品 ID(外键)') + product_number = Column(String(11), nullable=False, server_default="", index=True, comment='产品货号') + media_type = Column(String(20), nullable=False, server_default="image", index=True, comment='媒体类型:image, video') + r2_url = Column(String(500), nullable=False, server_default="", comment='媒体 URL(R2 原图 URL)') + thumbnail_r2_url = Column(String(500), nullable=False, server_default="", comment='缩略图 URL(R2 缩略图 URL)') + media_id = Column(String(32), nullable=False, server_default="", comment='素材ID') + media_alt = Column(String(200), nullable=False, server_default="", comment='ALT 标签/描述') + is_main = Column(Integer, nullable=False, server_default="0", comment='是否主图:0-否,1-是') + sort_order = Column(Integer, nullable=False, server_default="0", comment='排序顺序') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码,用于数据隔离') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/models/product_packaging.py b/models/product_packaging.py new file mode 100644 index 0000000000000000000000000000000000000000..431fa2652a18686676f00441fe46c8699970d15b --- /dev/null +++ b/models/product_packaging.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey +from sqlalchemy.sql import text +from database import Base + +class ProductPackaging(Base): + __tablename__ = "product_packaging" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + product_number = Column(String(11), nullable=False, server_default="", index=True, comment='产品货号') + model = Column(String(50), nullable=False, server_default="", comment='型号') + length = Column(Float, nullable=False, server_default="0", comment='长度 (cm)') + width = Column(Float, nullable=False, server_default="0", comment='宽度 (cm)') + height = Column(Float, nullable=False, server_default="0", comment='高度 (cm)') + volume = Column(Float, nullable=False, server_default="0", comment='体积 (cm³)') + weight = Column(Float, nullable=False, server_default="0", comment='重量 (g)') + company_code = Column(String(50), nullable=False, server_default="", index=True, comment='公司编码,用于数据隔离') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/models/site_visit.py b/models/site_visit.py new file mode 100644 index 0000000000000000000000000000000000000000..19fe1da3d8eed8c3bab31164c1786069f3340165 --- /dev/null +++ b/models/site_visit.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, DateTime, Index, text +from sqlalchemy.sql import func +from database import Base + + +class SiteVisit(Base): + __tablename__ = "site_visits" + + id = Column(Integer, primary_key=True, index=True, comment='主键 ID') + company_code = Column(String(50), nullable=False, index=True, comment='公司编码') + ip_address = Column(String(45), nullable=False, index=True, comment='访问IP地址') + visitor_id = Column(String(100), nullable=True, index=True, comment='访客ID') + + # 地理位置信息 + country = Column(String(50), nullable=True, comment='国家') + region = Column(String(50), nullable=True, comment='地区/省份') + city = Column(String(50), nullable=True, comment='城市') + latitude = Column(String(20), nullable=True, comment='纬度') + longitude = Column(String(20), nullable=True, comment='经度') + + # 访问信息 + user_agent = Column(String(500), nullable=True, comment='用户代理') + referer = Column(String(500), nullable=True, comment='来源页面') + page_url = Column(String(500), nullable=True, comment='访问页面') + visit_date = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='访问时间') + + __table_args__ = ( + Index('idx_company_visit_date', 'company_code', 'visit_date'), + Index('idx_company_country', 'company_code', 'country'), + ) diff --git a/models/template.py b/models/template.py new file mode 100644 index 0000000000000000000000000000000000000000..900e47db1dadac08c24c9550db15b1cccd72c3b0 --- /dev/null +++ b/models/template.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime +from sqlalchemy.sql import func, text +from database import Base + +class Template(Base): + __tablename__ = "templates" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + key = Column(String(50), nullable=False, server_default="", unique=True, index=True, comment='模板标识') + name = Column(String(100), nullable=False, server_default="", comment='模板名称') + description = Column(Text, nullable=False, default="", comment='模板描述') + color = Column(String(20), nullable=False, server_default="#000000", comment='模板主色调') + features = Column(Text, nullable=False, default="", comment='模板特性,JSON格式') + applicable_scene = Column(Text, nullable=False, default="", comment='适用场景') + regions = Column(Text, nullable=False, default="", comment='适用区域,JSON格式') + select_count = Column(Integer, nullable=False, server_default="0", comment='被选择总次数') + use_count = Column(Integer, nullable=False, server_default="0", comment='当前使用次数') + status = Column(Integer, nullable=False, server_default="0", comment='状态:0-不可用,1-可用') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..132b643933d51dc418de07787886fcefe7b9d197 --- /dev/null +++ b/models/user.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, text +from sqlalchemy.orm import relationship +from database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + username = Column(String(50), unique=True, nullable=False, server_default="", index=True, comment='用户名') + password = Column(String(255), nullable=False, server_default="", comment='密码') + email = Column(String(100), unique=True, nullable=False, server_default="", index=True, comment='邮箱') + is_superadmin = Column(Boolean, nullable=False, server_default="0", comment='是否为超级管理员') + company_code = Column(String(50), nullable=False, server_default="", comment='公司编码') + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间') + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..0642a02eade08b51b0ea337afb4caa4330d65d20 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +# FastAPI 及其依赖 +fastapi==0.104.1 +uvicorn[standard]==0.24.0.post1 +pydantic==2.5.2 +pydantic-settings==2.1.0 + +# 数据库相关 +sqlalchemy==2.0.23 +pymysql==1.1.0 + +# 安全相关 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 + +# 环境变量管理 +python-dotenv==1.0.0 + +# 其他 +python-magic==0.4.27 +boto3==1.34.0 +requests==2.31.0 + +# 开发工具 +black==23.12.1 +flake8==6.1.0 +isort==5.12.0 diff --git a/routers/__pycache__/auth.cpython-314.pyc b/routers/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..be87db14a6893c571e9c9bdcb9ccc08c48ffd056 Binary files /dev/null and b/routers/__pycache__/auth.cpython-314.pyc differ diff --git a/routers/__pycache__/categories.cpython-314.pyc b/routers/__pycache__/categories.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..beadd235ee4301cb520bd54257db3e203f4639b6 Binary files /dev/null and b/routers/__pycache__/categories.cpython-314.pyc differ diff --git a/routers/__pycache__/company.cpython-314.pyc b/routers/__pycache__/company.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..64be6395cfda29319ef981ea37ad44042cc0447d Binary files /dev/null and b/routers/__pycache__/company.cpython-314.pyc differ diff --git a/routers/__pycache__/contact.cpython-314.pyc b/routers/__pycache__/contact.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13e26afdf829a318f7d41e812832dd9bfeb01d0a Binary files /dev/null and b/routers/__pycache__/contact.cpython-314.pyc differ diff --git a/routers/__pycache__/customer_relationship.cpython-314.pyc b/routers/__pycache__/customer_relationship.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aaacf2991fe254fc9671ed85a64aaf108e7970f8 Binary files /dev/null and b/routers/__pycache__/customer_relationship.cpython-314.pyc differ diff --git a/routers/__pycache__/media.cpython-314.pyc b/routers/__pycache__/media.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8253f41a609dbf1b81d08c6cea2eed78d9ddc60a Binary files /dev/null and b/routers/__pycache__/media.cpython-314.pyc differ diff --git a/routers/__pycache__/member_level.cpython-314.pyc b/routers/__pycache__/member_level.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b8568ddb6dd8cd529ba9f29157aa0757a4072ef Binary files /dev/null and b/routers/__pycache__/member_level.cpython-314.pyc differ diff --git a/routers/__pycache__/membership.cpython-314.pyc b/routers/__pycache__/membership.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2e08f6eb6231a95d5df6eba77847c6d01f3647ae Binary files /dev/null and b/routers/__pycache__/membership.cpython-314.pyc differ diff --git a/routers/__pycache__/operation_log.cpython-314.pyc b/routers/__pycache__/operation_log.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..500c58fc0e94e196713d75c5faa8bd1d825c86f1 Binary files /dev/null and b/routers/__pycache__/operation_log.cpython-314.pyc differ diff --git a/routers/__pycache__/product_certifications.cpython-314.pyc b/routers/__pycache__/product_certifications.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9468574b4d279a309358a3f874b22870ff88c83a Binary files /dev/null and b/routers/__pycache__/product_certifications.cpython-314.pyc differ diff --git a/routers/__pycache__/product_media.cpython-314.pyc b/routers/__pycache__/product_media.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4793e383067c8cf10d3602cb39272fb53b305ed4 Binary files /dev/null and b/routers/__pycache__/product_media.cpython-314.pyc differ diff --git a/routers/__pycache__/product_packaging.cpython-314.pyc b/routers/__pycache__/product_packaging.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f7eb6a53c21ddb80bf368ac4cb93b8d2acdb2856 Binary files /dev/null and b/routers/__pycache__/product_packaging.cpython-314.pyc differ diff --git a/routers/__pycache__/product_utils.cpython-314.pyc b/routers/__pycache__/product_utils.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9573a6ee398cf77d06394f29ff2b4117fa988e2e Binary files /dev/null and b/routers/__pycache__/product_utils.cpython-314.pyc differ diff --git a/routers/__pycache__/products.cpython-314.pyc b/routers/__pycache__/products.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9c6b294ffbb3653646dfc590fd0943ade5224f8 Binary files /dev/null and b/routers/__pycache__/products.cpython-314.pyc differ diff --git a/routers/__pycache__/r2_config.cpython-314.pyc b/routers/__pycache__/r2_config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..df8c09f07377134bda8de59dc8898ce2d4a1562a Binary files /dev/null and b/routers/__pycache__/r2_config.cpython-314.pyc differ diff --git a/routers/__pycache__/template.cpython-314.pyc b/routers/__pycache__/template.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5b5d1da2a0851e93787891a57bac647eac1b9d1 Binary files /dev/null and b/routers/__pycache__/template.cpython-314.pyc differ diff --git a/routers/auth.py b/routers/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..58df4fdcc7de3198a4162c35341e24ee7d0d675b --- /dev/null +++ b/routers/auth.py @@ -0,0 +1,201 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from datetime import timedelta +from database import get_db +from models.user import User +from schemas.user import UserCreate, UserResponse, Token +from utils.auth import verify_password, get_password_hash, create_access_token, decode_token, ACCESS_TOKEN_EXPIRE_MINUTES + +router = APIRouter() +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + """获取当前用户""" + from jose import JWTError, jwt + from utils.auth import SECRET_KEY, ALGORITHM + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError as e: + print(f"JWTError: {e}") + raise credentials_exception + + user = db.query(User).filter(User.username == username).first() + if user is None: + raise credentials_exception + + return user + +async def get_current_superadmin(current_user: User = Depends(get_current_user)): + """获取当前超级管理员或admin用户""" + if not current_user.is_superadmin and current_user.username != 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user + +def is_admin_user(current_user: User) -> bool: + """判断是否为admin用户,admin用户可以查看所有数据""" + return current_user.username == 'admin' + +@router.post("/register", response_model=UserResponse) +def register(user: UserCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_superadmin)): + """注册用户(仅超级管理员可注册)""" + # 检查用户名是否已存在 + db_user = db.query(User).filter(User.username == user.username).first() + if db_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered" + ) + + # 检查邮箱是否已存在 + db_user = db.query(User).filter(User.email == user.email).first() + if db_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # 创建新用户 + hashed_password = get_password_hash(user.password) + db_user = User( + username=user.username, + email=user.email, + password=hashed_password, + company_code=user.company_code, + is_superadmin=user.is_superadmin or False + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + +from fastapi import APIRouter, Depends, HTTPException, status, Request +from pydantic import BaseModel + +class PasswordChange(BaseModel): + old_password: str + new_password: str + +@router.post("/login", response_model=Token) +async def login(request: Request, db: Session = Depends(get_db)): + """用户登录""" + # 尝试解析JSON数据 + try: + data = await request.json() + username = data.get("username") + password = data.get("password") + except Exception: + # 如果JSON解析失败,尝试解析表单数据 + form_data = await request.form() + username = form_data.get("username") + password = form_data.get("password") + + # 查找用户 + user = db.query(User).filter(User.username == username).first() + if not user or not verify_password(password, user.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 创建访问令牌 + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} + +@router.get("/me", response_model=UserResponse) +def get_me(current_user: User = Depends(get_current_user)): + """获取当前用户信息""" + return current_user + +@router.put("/change-password") +def change_password(password_data: PasswordChange, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """修改当前用户密码""" + # 验证原密码 + if not verify_password(password_data.old_password, current_user.password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="原密码不正确" + ) + # 更新密码 + hashed_password = get_password_hash(password_data.new_password) + current_user.password = hashed_password + db.commit() + db.refresh(current_user) + return {"detail": "密码修改成功"} + +@router.get("/users", response_model=list[UserResponse]) +def get_users(current_user: User = Depends(get_current_superadmin), db: Session = Depends(get_db)): + """获取用户列表(仅超级管理员可访问)""" + users = db.query(User).all() + return users + +@router.put("/users/{user_id}", response_model=UserResponse) +def update_user(user_id: int, user: UserCreate, current_user: User = Depends(get_current_superadmin), db: Session = Depends(get_db)): + """更新用户信息(仅超级管理员可访问)""" + db_user = db.query(User).filter(User.id == user_id).first() + if not db_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # 检查是否为admin账号,admin账号的用户名不能修改 + if db_user.username == "admin" and user.username and user.username != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin username cannot be changed" + ) + + # 更新用户信息 + if user.username: + db_user.username = user.username + if user.email: + db_user.email = user.email + if user.password: + db_user.password = get_password_hash(user.password) + if user.company_code: + db_user.company_code = user.company_code + if hasattr(user, "is_superadmin"): + db_user.is_superadmin = user.is_superadmin + + db.commit() + db.refresh(db_user) + return db_user + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, current_user: User = Depends(get_current_superadmin), db: Session = Depends(get_db)): + """删除用户(仅超级管理员可访问)""" + db_user = db.query(User).filter(User.id == user_id).first() + if not db_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # 检查是否为admin账号,admin账号永久不可删除 + if db_user.username == "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin account cannot be deleted" + ) + + db.delete(db_user) + db.commit() + return {"detail": "User deleted successfully"} diff --git a/routers/categories.py b/routers/categories.py new file mode 100644 index 0000000000000000000000000000000000000000..48d585e3d68068b2821944afb5bde83da932803f --- /dev/null +++ b/routers/categories.py @@ -0,0 +1,299 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional +import uuid +import re + +from database import get_db +from models.category import Category +from models.product import Product +from models.user import User +from schemas.category import CategoryResponse, CategoryCreate, CategoryUpdate, CategoryTreeResponse +from routers.auth import get_current_user, is_admin_user + +router = APIRouter() + +def generate_level_code() -> str: + """生成6位字母数字组合编码""" + # 使用UUID生成并截取前6位,确保包含字母和数字 + while True: + code = uuid.uuid4().hex[:6].lower() + # 检查是否同时包含字母和数字 + if re.search(r'[a-z]', code) and re.search(r'[0-9]', code): + return code + +def get_category_level_by_code(db: Session, category_code: str, company_code: str) -> int: + """通过编码获取分类的层级,0为顶级""" + if not category_code: + return 0 + # 通过下划线数量判断层级 + return category_code.count('_') + +def build_category_code(db: Session, parent_code: str, company_code: str) -> str: + """构建级联编码""" + if not parent_code: + # 顶级分类,直接生成编码 + return generate_level_code() + + parent = db.query(Category).filter( + Category.code == parent_code, + Category.company_code == company_code + ).first() + if not parent: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Parent category not found" + ) + + # 生成当前层级编码,拼接父级编码 + level_code = generate_level_code() + return f"{parent_code}_{level_code}" + +def build_category_tree(categories: List[Category], parent_code: str = "") -> List[CategoryTreeResponse]: + """构建分类树形结构""" + tree = [] + for cat in categories: + if cat.parent_code == parent_code: + children = build_category_tree(categories, cat.code) + tree.append(CategoryTreeResponse( + **cat.__dict__, + children=children + )) + return tree + +@router.get("/", response_model=List[CategoryResponse]) +async def get_categories( + parent_code: Optional[str] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取分类列表""" + query = db.query(Category) + if not is_admin_user(current_user): + query = query.filter(Category.company_code == current_user.company_code) + if parent_code is not None: + query = query.filter(Category.parent_code == parent_code) + categories = query.order_by(Category.sort_order, Category.id).all() + + # 计算每个分类的产品数量 + result = [] + for category in categories: + product_count = db.query(Product).filter( + Product.category_id == category.id + ) + if not is_admin_user(current_user): + product_count = product_count.filter(Product.company_code == current_user.company_code) + result.append(CategoryResponse( + **category.__dict__, + product_count=product_count.count() + )) + return result + +@router.get("/tree", response_model=List[CategoryTreeResponse]) +async def get_category_tree( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取分类树形结构""" + query = db.query(Category) + if not is_admin_user(current_user): + query = query.filter(Category.company_code == current_user.company_code) + + categories = query.order_by(Category.sort_order, Category.id).all() + + return build_category_tree(categories, "") + +@router.get("/{category_id}", response_model=CategoryResponse) +async def get_category( + category_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取单个分类详情""" + query = db.query(Category).filter(Category.id == category_id) + if not is_admin_user(current_user): + query = query.filter(Category.company_code == current_user.company_code) + + category = query.first() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + # 计算产品数量 + product_count = db.query(Product).filter(Product.category_id == category.id) + if not is_admin_user(current_user): + product_count = product_count.filter(Product.company_code == current_user.company_code) + + return CategoryResponse( + **category.__dict__, + product_count=product_count.count() + ) + +@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) +async def create_category( + category: CategoryCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """创建新分类""" + # 检查层级限制(最多三级) + level = get_category_level_by_code(db, category.parent_code, current_user.company_code) + if level >= 3: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Maximum category level is 3" + ) + + # 检查同级分类名称是否已存在 + existing = db.query(Category).filter( + Category.name == category.name, + Category.parent_code == category.parent_code, + Category.company_code == current_user.company_code + ).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category name already exists in the same level" + ) + + # 构建编码 + code = build_category_code(db, category.parent_code, current_user.company_code) + + # 创建分类 + category_data = category.model_dump() + if not is_admin_user(current_user): + category_data['company_code'] = current_user.company_code + category_data['code'] = code + db_category = Category(**category_data) + db.add(db_category) + db.commit() + db.refresh(db_category) + + return CategoryResponse( + **db_category.__dict__, + product_count=0 + ) + +@router.put("/{category_id}", response_model=CategoryResponse) +async def update_category( + category_id: int, + category_update: CategoryUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """更新分类""" + query = db.query(Category).filter(Category.id == category_id) + if not is_admin_user(current_user): + query = query.filter(Category.company_code == current_user.company_code) + + db_category = query.first() + if not db_category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + update_data = category_update.model_dump(exclude_unset=True) + + # 检查是否需要更新父分类 + new_parent_code = update_data.get('parent_code', db_category.parent_code) + if new_parent_code != db_category.parent_code: + # 检查层级限制(最多三级) + level = get_category_level_by_code(db, new_parent_code, current_user.company_code) + if level >= 3: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Maximum category level is 3" + ) + + # 检查是否有子分类,如果有则不能改变父分类 + child_count = db.query(Category).filter( + Category.parent_code == db_category.code, + Category.company_code == current_user.company_code + ).count() + if child_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot change parent category of category with children" + ) + + # 重新构建编码 + new_code = build_category_code(db, new_parent_code, current_user.company_code) + update_data['code'] = new_code + + # 检查新名称是否与同级分类冲突 + new_name = update_data.get('name', db_category.name) + if new_name != db_category.name or new_parent_code != db_category.parent_code: + existing = db.query(Category).filter( + Category.name == new_name, + Category.parent_code == new_parent_code, + Category.company_code == current_user.company_code, + Category.id != category_id + ).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category name already exists in the same level" + ) + + # 更新分类 + for field, value in update_data.items(): + setattr(db_category, field, value) + db.commit() + db.refresh(db_category) + + # 计算产品数量 + product_count = db.query(Product).filter(Product.category_id == db_category.id) + if not is_admin_user(current_user): + product_count = product_count.filter(Product.company_code == current_user.company_code) + + return CategoryResponse( + **db_category.__dict__, + product_count=product_count.count() + ) + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_category( + category_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """删除分类""" + query = db.query(Category).filter(Category.id == category_id) + if not is_admin_user(current_user): + query = query.filter(Category.company_code == current_user.company_code) + + db_category = query.first() + if not db_category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + # 检查是否有子分类 + child_count = db.query(Category).filter( + Category.parent_code == db_category.code, + Category.company_code == current_user.company_code + ).count() + if child_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot delete category with {child_count} child categories" + ) + + # 检查是否有产品属于该分类 + product_count = db.query(Product).filter( + Product.category_id == category_id, + Product.company_code == current_user.company_code + ).count() + if product_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot delete category with {product_count} products" + ) + + db.delete(db_category) + db.commit() + return None diff --git a/routers/company.py b/routers/company.py new file mode 100644 index 0000000000000000000000000000000000000000..5435941edfd5d24f300e3e8ac6495ac67638dcf6 --- /dev/null +++ b/routers/company.py @@ -0,0 +1,241 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import Optional, List + +from database import get_db +from models.company import Company +from models.template import Template +from models.user import User +from schemas.company import CompanyResponse, CompanyCreate, CompanyUpdate +from routers.auth import get_current_user, get_current_superadmin +from routers.operation_log import create_operation_log + +router = APIRouter() + +# 获取公司列表 +@router.get("/", response_model=List[CompanyResponse]) +async def get_companies( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # 普通用户只能查看自己公司的信息 + if not current_user.is_superadmin: + companies = db.query(Company).filter( + Company.company_code == current_user.company_code + ).all() + else: + # 超级管理员可以查看所有公司 + companies = db.query(Company).all() + return companies + +# 通过公司编码获取公司信息 +@router.get("/{company_code}", response_model=CompanyResponse) +async def get_company_by_code( + company_code: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # 检查用户权限:超级管理员可以访问所有公司,普通用户只能访问自己公司 + if not current_user.is_superadmin and current_user.company_code != company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to access this company" + ) + + company = db.query(Company).filter(Company.company_code == company_code).first() + if company is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Company with code '{company_code}' not found" + ) + return company + +# 创建公司信息(仅超级管理员可操作) +@router.post("/", response_model=CompanyResponse) +async def create_company( + company: CompanyCreate, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + # 自动生成11位字符串+数字组合的公司编码,确保全局唯一 + import uuid + while True: + # 生成UUID并转换为字符串,取前8位字符和后3位数字 + uuid_str = str(uuid.uuid4()).replace('-', '') + # 取前8位字母和后3位数字,确保编码长度为11位 + company_code = uuid_str[:8] + uuid_str[-3:] + # 检查公司编码是否已存在 + existing_company = db.query(Company).filter(Company.company_code == company_code).first() + if not existing_company: + break + + # 创建公司信息,使用自动生成的公司编码 + company_data = company.model_dump() + company_data['company_code'] = company_code + + # 如果没有指定模板,使用默认模板 Home + if not company_data.get('template'): + company_data['template'] = 'Home' + + db_company = Company(**company_data) + db.add(db_company) + db.commit() + db.refresh(db_company) + + # 更新默认模板的统计数据 + default_template = db.query(Template).filter(Template.key == db_company.template).first() + if default_template: + default_template.select_count = (default_template.select_count or 0) + 1 + default_template.use_count = (default_template.use_count or 0) + 1 + db.commit() + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="company", + operation_type="add", + operation_detail=f"新增公司:{db_company.name}(编码:{db_company.company_code})", + company_code=db_company.company_code + ) + + return db_company + +# 通过公司编码更新公司信息 +@router.put("/{company_code}", response_model=CompanyResponse) +async def update_company( + company_code: str, + company_update: CompanyUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # 检查用户权限:超级管理员可以更新所有公司,普通用户只能更新自己公司 + if not current_user.is_superadmin and current_user.company_code != company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to update this company" + ) + + company = db.query(Company).filter(Company.company_code == company_code).first() + if company is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Company with code '{company_code}' not found" + ) + + # 记录修改前的数据 + before_data_dict = { + "name": company.name, + "level": company.level, + "phone": company.phone, + "email": company.email, + "address": company.address, + "description": company.description, + "template": company.template + } + + # 更新公司信息 + update_data = company_update.model_dump(exclude_unset=True) + + # 处理模板更新逻辑 + old_template_key = company.template + new_template_key = update_data.get('template') + + if new_template_key and old_template_key != new_template_key: + # 旧模板使用次数-1 + if old_template_key: + old_template = db.query(Template).filter(Template.key == old_template_key).first() + if old_template: + old_template.use_count = max(0, (old_template.use_count or 0) - 1) + + # 新模板选择次数+1,使用次数+1 + new_template = db.query(Template).filter(Template.key == new_template_key).first() + if new_template: + new_template.select_count = (new_template.select_count or 0) + 1 + new_template.use_count = (new_template.use_count or 0) + 1 + + for field, value in update_data.items(): + setattr(company, field, value) + + db.commit() + db.refresh(company) + + # 记录修改后的数据 + after_data_dict = { + "name": company.name, + "level": company.level, + "phone": company.phone, + "email": company.email, + "address": company.address, + "description": company.description, + "template": company.template + } + + # 只记录有修改的字段,使用中文解析 + modified_fields = [] + field_names = { + "name": "公司名称", + "level": "会员等级", + "phone": "联系电话", + "email": "电子邮箱", + "address": "公司地址", + "description": "公司描述", + "template": "模板选择" + } + + for field, chinese_name in field_names.items(): + if before_data_dict[field] != after_data_dict[field]: + modified_fields.append(f"{chinese_name}:{before_data_dict[field]} → {after_data_dict[field]}") + + # 生成中文描述的修改内容 + if modified_fields: + modification_detail = ";".join(modified_fields) + else: + modification_detail = "无字段修改" + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="company", + operation_type="edit", + operation_detail=f"编辑公司:{company.name}(编码:{company.company_code})", + before_data=modification_detail, + after_data=None, + company_code=company.company_code + ) + + return company + +# 通过公司编码删除公司信息(仅超级管理员可操作) +@router.delete("/{company_code}") +async def delete_company( + company_code: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + company = db.query(Company).filter(Company.company_code == company_code).first() + if company is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Company with code '{company_code}' not found" + ) + + # 记录公司名称和编码,用于日志 + company_name = company.name + company_code = company.company_code + + db.delete(company) + db.commit() + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="company", + operation_type="delete", + operation_detail=f"删除公司:{company_name}(编码:{company_code})", + company_code=company_code + ) + + return {"message": f"Company with code '{company_code}' deleted successfully"} diff --git a/routers/contact.py b/routers/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..fe394ec1612f4a1d2771c645a3e9bd84be110c03 --- /dev/null +++ b/routers/contact.py @@ -0,0 +1,107 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models.contact import Contact +from schemas.contact import ContactCreate, ContactUpdate, ContactResponse +from routers.auth import get_current_user, is_admin_user +from models.user import User + +router = APIRouter() + +# 获取公司的联系信息列表 +@router.get("/company/{company_code}", response_model=List[ContactResponse]) +async def get_company_contacts( + company_code: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # 检查用户权限:admin用户和超级管理员可以访问所有公司,普通用户只能访问自己公司 + if not is_admin_user(current_user) and not current_user.is_superadmin and current_user.company_code != company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to access this company's contacts" + ) + + contacts = db.query(Contact).filter(Contact.company_code == company_code).all() + return contacts + +# 创建联系信息 +@router.post("/company/{company_code}", response_model=ContactResponse) +async def create_contact( + contact: ContactCreate, + company_code: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # 检查用户权限:admin用户和超级管理员可以访问所有公司,普通用户只能访问自己公司 + if not is_admin_user(current_user) and not current_user.is_superadmin and current_user.company_code != company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to create contacts for this company" + ) + + db_contact = Contact( + **contact.model_dump(), + company_code=company_code + ) + db.add(db_contact) + db.commit() + db.refresh(db_contact) + return db_contact + +# 更新联系信息 +@router.put("/{contact_id}", response_model=ContactResponse) +async def update_contact( + contact_id: int, + contact: ContactUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + db_contact = db.query(Contact).filter(Contact.id == contact_id).first() + if not db_contact: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Contact not found" + ) + + # 检查用户权限:admin用户和超级管理员可以访问所有公司,普通用户只能访问自己公司 + if not is_admin_user(current_user) and not current_user.is_superadmin and db_contact.company_code != current_user.company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to update this contact" + ) + + update_data = contact.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_contact, field, value) + + db.commit() + db.refresh(db_contact) + return db_contact + +# 删除联系信息 +@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_contact( + contact_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + db_contact = db.query(Contact).filter(Contact.id == contact_id).first() + if not db_contact: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Contact not found" + ) + + # 检查用户权限:admin用户和超级管理员可以访问所有公司,普通用户只能访问自己公司 + if not is_admin_user(current_user) and not current_user.is_superadmin and db_contact.company_code != current_user.company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to delete this contact" + ) + + db.delete(db_contact) + db.commit() + return None diff --git a/routers/customer_relationship.py b/routers/customer_relationship.py new file mode 100644 index 0000000000000000000000000000000000000000..21ccac6ca13ce7eaaa61c2da742d342efa45de23 --- /dev/null +++ b/routers/customer_relationship.py @@ -0,0 +1,249 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional +import json + +from database import get_db +from models.customer_relationship import CustomerRelationship +from models.user import User +from schemas.customer_relationship import CustomerRelationshipResponse, CustomerRelationshipCreate, CustomerRelationshipUpdate +from routers.auth import get_current_user, is_admin_user +from routers.operation_log import create_operation_log + +router = APIRouter() + +# 获取客户关系列表 +@router.get("/", response_model=List[CustomerRelationshipResponse]) +async def get_customer_relationships( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # admin用户和超级管理员可以查看所有客户关系 + if is_admin_user(current_user) or current_user.is_superadmin: + customer_relationships = db.query(CustomerRelationship).all() + else: + # 普通用户只能查看自己公司的客户关系 + customer_relationships = db.query(CustomerRelationship).filter( + CustomerRelationship.company_code == current_user.company_code + ).all() + + # 转换social_media字段从JSON字符串到字典 + for cr in customer_relationships: + if cr.social_media: + try: + cr.social_media = json.loads(cr.social_media) + except: + cr.social_media = {} + + return customer_relationships + +# 通过ID获取客户关系详情 +@router.get("/{customer_id}", response_model=CustomerRelationshipResponse) +async def get_customer_relationship( + customer_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + customer_relationship = db.query(CustomerRelationship).filter(CustomerRelationship.id == customer_id).first() + if customer_relationship is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Customer relationship with ID '{customer_id}' not found" + ) + + # 检查权限:admin用户和超级管理员可以查看所有客户关系,普通用户只能查看自己公司的 + if not is_admin_user(current_user) and not current_user.is_superadmin and customer_relationship.company_code != current_user.company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to access this customer relationship" + ) + + # 转换social_media字段从JSON字符串到字典 + if customer_relationship.social_media: + try: + customer_relationship.social_media = json.loads(customer_relationship.social_media) + except: + customer_relationship.social_media = {} + + return customer_relationship + +# 创建客户关系 +@router.post("/", response_model=CustomerRelationshipResponse) +async def create_customer_relationship( + customer_relationship: CustomerRelationshipCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # 检查权限:admin用户和超级管理员可以为任何公司创建客户关系,普通用户只能为自己公司创建 + if not is_admin_user(current_user) and not current_user.is_superadmin and customer_relationship.company_code != current_user.company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to create customer relationships for other companies" + ) + + # 转换social_media字典为JSON字符串 + customer_data = customer_relationship.model_dump() + if customer_data.get('social_media'): + customer_data['social_media'] = json.dumps(customer_data['social_media'], ensure_ascii=False) + + db_customer = CustomerRelationship(**customer_data) + db.add(db_customer) + db.commit() + db.refresh(db_customer) + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="customer_relationship", + operation_type="add", + operation_detail=f"新增客户关系:{db_customer.customer_name}(联系人:{db_customer.contact_person})", + company_code=current_user.company_code + ) + + # 转换social_media字段从JSON字符串到字典 + if db_customer.social_media: + try: + db_customer.social_media = json.loads(db_customer.social_media) + except: + db_customer.social_media = {} + + return db_customer + +# 更新客户关系 +@router.put("/{customer_id}", response_model=CustomerRelationshipResponse) +async def update_customer_relationship( + customer_id: int, + customer_update: CustomerRelationshipUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + customer_relationship = db.query(CustomerRelationship).filter(CustomerRelationship.id == customer_id).first() + if customer_relationship is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Customer relationship with ID '{customer_id}' not found" + ) + + # 检查权限:admin用户和超级管理员可以更新所有客户关系,普通用户只能更新自己公司的 + if not is_admin_user(current_user) and not current_user.is_superadmin and customer_relationship.company_code != current_user.company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to update this customer relationship" + ) + + # 记录修改前的数据 + before_data_dict = { + "customer_name": customer_relationship.customer_name, + "contact_person": customer_relationship.contact_person, + "phone": customer_relationship.phone, + "email": customer_relationship.email, + "social_media": customer_relationship.social_media, + "address": customer_relationship.address, + "notes": customer_relationship.notes + } + + # 更新客户关系信息 + update_data = customer_update.model_dump(exclude_unset=True) + if 'social_media' in update_data: + update_data['social_media'] = json.dumps(update_data['social_media'], ensure_ascii=False) + + for field, value in update_data.items(): + setattr(customer_relationship, field, value) + + db.commit() + db.refresh(customer_relationship) + + # 记录修改后的数据 + after_data_dict = { + "customer_name": customer_relationship.customer_name, + "contact_person": customer_relationship.contact_person, + "phone": customer_relationship.phone, + "email": customer_relationship.email, + "social_media": customer_relationship.social_media, + "address": customer_relationship.address, + "notes": customer_relationship.notes + } + + # 只记录有修改的字段,使用中文解析 + modified_fields = [] + field_names = { + "customer_name": "客户名称", + "contact_person": "联系人", + "phone": "电话", + "email": "邮箱", + "social_media": "社交平台账号", + "address": "地址", + "notes": "备注" + } + + for field, chinese_name in field_names.items(): + if before_data_dict[field] != after_data_dict[field]: + modified_fields.append(f"{chinese_name}:{before_data_dict[field]} → {after_data_dict[field]}") + + # 生成中文描述的修改内容 + if modified_fields: + modification_detail = ";".join(modified_fields) + else: + modification_detail = "无字段修改" + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="customer_relationship", + operation_type="edit", + operation_detail=f"编辑客户关系:{customer_relationship.customer_name}(联系人:{customer_relationship.contact_person})", + before_data=modification_detail, + after_data=None, + company_code=current_user.company_code + ) + + # 转换social_media字段从JSON字符串到字典 + if customer_relationship.social_media: + try: + customer_relationship.social_media = json.loads(customer_relationship.social_media) + except: + customer_relationship.social_media = {} + + return customer_relationship + +# 删除客户关系 +@router.delete("/{customer_id}") +async def delete_customer_relationship( + customer_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + customer_relationship = db.query(CustomerRelationship).filter(CustomerRelationship.id == customer_id).first() + if customer_relationship is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Customer relationship with ID '{customer_id}' not found" + ) + + # 检查权限:admin用户和超级管理员可以删除所有客户关系,普通用户只能删除自己公司的 + if not is_admin_user(current_user) and not current_user.is_superadmin and customer_relationship.company_code != current_user.company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to delete this customer relationship" + ) + + # 记录客户关系名称和联系人,用于日志 + customer_name = customer_relationship.customer_name + contact_person = customer_relationship.contact_person + + db.delete(customer_relationship) + db.commit() + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="customer_relationship", + operation_type="delete", + operation_detail=f"删除客户关系:{customer_name}(联系人:{contact_person})", + company_code=current_user.company_code + ) + + return {"message": f"Customer relationship with ID '{customer_id}' deleted successfully"} \ No newline at end of file diff --git a/routers/media.py b/routers/media.py new file mode 100644 index 0000000000000000000000000000000000000000..243b0e61b6a72b01cdb1a27e173b934d68339a01 --- /dev/null +++ b/routers/media.py @@ -0,0 +1,1236 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import List, Optional +import uuid +import hashlib +import os +import logging +from datetime import datetime +from dotenv import load_dotenv + +from database import get_db +from models.media import Media, MediaDirectory, MediaTag, MediaTagMapping, MediaStatus +from models.company_r2_config import CompanyR2Config +from models.user import User +from schemas.media import ( + MediaResponse, MediaCreate, MediaUpdate, + MediaDirectoryResponse, MediaDirectoryCreate, MediaDirectoryUpdate, + MediaTagResponse, MediaTagCreate, MediaTagUpdate, + MediaBatchResponse, + MediaBatchDeleteRequest, MediaBatchUpdateRequest, MediaBatchTagRequest +) +from routers.auth import get_current_user +from utils.r2_uploader import R2Uploader, R2Config, get_r2_uploader + +router = APIRouter() + +# 配置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +def get_r2_config(db: Session, company_code: str) -> Optional[R2Config]: + """获取 R2 配置,优先级:1.当前公司配置 → 2.'0000'公司配置 → 3..env 环境配置""" + + # 重新加载 .env 文件,确保获取最新配置 + load_dotenv() + + # 1. 优先获取当前公司的 R2 配置 + logger.info(f"🔍 查找 R2 配置 - 公司编码:{company_code}") + config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == company_code + ).first() + + if config and config.r2_enabled: + logger.info(f"✅ 使用当前公司 R2 配置:{company_code}") + return R2Config({ + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled + }) + + # 2. 获取默认公司(0000)的 R2 配置 + if company_code != "0000": + config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == "0000" + ).first() + + if config and config.r2_enabled: + logger.info(f"✅ 使用默认公司 R2 配置:0000") + return R2Config({ + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled + }) + + # 3. 从 .env 文件读取 R2 配置 + logger.info(f"ℹ️ 尝试从 .env 文件读取 R2 配置") + r2_account_id = os.getenv("R2_ACCOUNT_ID", "") + r2_access_key_id = os.getenv("R2_ACCESS_KEY_ID", "") + r2_secret_access_key = os.getenv("R2_SECRET_ACCESS_KEY", "") + r2_bucket_name = os.getenv("R2_BUCKET_NAME", "") + r2_public_url = os.getenv("R2_PUBLIC_URL", "") + r2_enabled = os.getenv("R2_ENABLED", "true").lower() in ("true", "1", "yes") + + logger.info(f" - R2_ACCOUNT_ID: {r2_account_id[:10] if r2_account_id else 'None'}...") + logger.info(f" - R2_ACCESS_KEY_ID: {r2_access_key_id[:10] if r2_access_key_id else 'None'}...") + logger.info(f" - R2_BUCKET_NAME: {r2_bucket_name}") + logger.info(f" - R2_ENABLED: {r2_enabled}") + + if r2_account_id and r2_access_key_id and r2_secret_access_key and r2_bucket_name: + logger.info(f"✅ 使用 .env 文件中的 R2 配置") + return R2Config({ + 'r2_account_id': r2_account_id, + 'r2_access_key_id': r2_access_key_id, + 'r2_secret_access_key': r2_secret_access_key, + 'r2_bucket_name': r2_bucket_name, + 'r2_public_url': r2_public_url, + 'r2_enabled': r2_enabled + }) + + logger.error(f"❌ 未找到任何 R2 配置") + return None + + +def generate_media_id() -> str: + """生成32位UUID作为media_id""" + return uuid.uuid4().hex + + +def generate_file_hash(file_path: str) -> str: + """生成文件MD5哈希""" + hash_md5 = hashlib.md5() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + + +# ==================== 目录管理(先排,避免与 {media_id} 冲突) ==================== + +@router.get("/directories", response_model=List[MediaDirectoryResponse]) +async def get_media_directories( + parent_id: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取媒体目录列表""" + print(f"获取目录列表 - parent_id: {parent_id}, company_code: {current_user.company_code}") + + query = db.query(MediaDirectory).filter( + MediaDirectory.company_code == current_user.company_code + ) + + if parent_id is not None: + query = query.filter(MediaDirectory.parent_id == parent_id) + else: + query = query.filter(MediaDirectory.parent_id == None) + + directories = query.order_by(MediaDirectory.sort_order, MediaDirectory.created_at).all() + + # 为每个目录计算总大小和文件数量 + result = [] + for directory in directories: + # 查询该目录下的所有媒体文件总大小(只统计未删除的) + total_size = db.query(func.sum(Media.file_size)).filter( + Media.directory_id == directory.id, + Media.company_code == current_user.company_code, + Media.is_deleted == False + ).scalar() or 0 + + # 查询该目录下的媒体文件数量(只统计未删除的) + media_count = db.query(func.count(Media.media_id)).filter( + Media.directory_id == directory.id, + Media.company_code == current_user.company_code, + Media.is_deleted == False + ).scalar() or 0 + + # 创建响应对象 + dir_response = MediaDirectoryResponse( + id=directory.id, + name=directory.name, + parent_id=directory.parent_id, + path=directory.path, + level=directory.level, + sort_order=directory.sort_order, + cover_r2_key=directory.cover_r2_key, + cover_r2_url=directory.cover_r2_url, + description=directory.description, + is_public=directory.is_public, + media_count=media_count, + total_size=total_size, + company_code=directory.company_code, + created_at=directory.created_at, + updated_at=directory.updated_at + ) + result.append(dir_response) + + print(f"查询到 {len(result)} 个目录: {[d.name for d in result]}") + + return result + + +@router.get("/directories/{directory_id}", response_model=MediaDirectoryResponse) +async def get_media_directory( + directory_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取单个媒体目录""" + directory = db.query(MediaDirectory).filter( + MediaDirectory.id == directory_id, + MediaDirectory.company_code == current_user.company_code + ).first() + + if not directory: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Directory not found" + ) + + return directory + + +@router.post("/directories", response_model=MediaDirectoryResponse, status_code=status.HTTP_201_CREATED) +async def create_media_directory( + directory_data: MediaDirectoryCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建媒体目录""" + try: + print(f"创建文件夹请求 - name: {directory_data.name}, parent_id: {directory_data.parent_id}, company_code: {current_user.company_code}") + + # 验证文件夹名称 + if not directory_data.name or directory_data.name.strip() == '': + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="文件夹名称不能为空" + ) + + # 验证父目录 + parent = None + if directory_data.parent_id is not None: + parent = db.query(MediaDirectory).filter( + MediaDirectory.id == directory_data.parent_id, + MediaDirectory.company_code == current_user.company_code + ).first() + + if not parent: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="父目录不存在" + ) + + # 检查同级目录是否已存在同名文件夹 + existing_dir = db.query(MediaDirectory).filter( + MediaDirectory.name == directory_data.name, + MediaDirectory.company_code == current_user.company_code, + MediaDirectory.parent_id == directory_data.parent_id + ).first() + + if existing_dir: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="同级目录下已存在同名文件夹" + ) + + # 生成路径 + path = directory_data.name + level = 0 + + if parent: + path = f"{parent.path}/{directory_data.name}" + level = parent.level + 1 + + directory = MediaDirectory( + name=directory_data.name, + parent_id=directory_data.parent_id, + company_code=current_user.company_code, + path=path, + level=level, + sort_order=directory_data.sort_order or 0, + cover_r2_key=directory_data.cover_r2_key, + cover_r2_url=directory_data.cover_r2_url, + description=directory_data.description, + is_public=directory_data.is_public or False + ) + + db.add(directory) + db.commit() + db.refresh(directory) + + print(f"文件夹创建成功 - id: {directory.id}, name: {directory.name}") + + return directory + + except HTTPException: + db.rollback() + raise + except Exception as e: + db.rollback() + print(f"创建文件夹失败: {str(e)}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"创建文件夹失败: {str(e)}" + ) + + +@router.put("/directories/{directory_id}", response_model=MediaDirectoryResponse) +async def update_media_directory( + directory_id: int, + directory_update: MediaDirectoryUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新媒体目录""" + directory = db.query(MediaDirectory).filter( + MediaDirectory.id == directory_id, + MediaDirectory.company_code == current_user.company_code + ).first() + + if not directory: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Directory not found" + ) + + update_data = directory_update.model_dump(exclude_unset=True) + + # 如果更新父目录,需要重新计算路径 + if 'parent_id' in update_data and update_data['parent_id'] != directory.parent_id: + if update_data['parent_id']: + parent = db.query(MediaDirectory).filter( + MediaDirectory.id == update_data['parent_id'], + MediaDirectory.company_code == current_user.company_code + ).first() + + if parent: + directory.path = f"{parent.path}/{directory.name}" + directory.level = parent.level + 1 + else: + directory.path = directory.name + directory.level = 0 + + for field, value in update_data.items(): + setattr(directory, field, value) + + db.commit() + db.refresh(directory) + + return directory + + +@router.delete("/directories/{directory_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_media_directory( + directory_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除媒体目录""" + directory = db.query(MediaDirectory).filter( + MediaDirectory.id == directory_id, + MediaDirectory.company_code == current_user.company_code + ).first() + + if not directory: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Directory not found" + ) + + # 检查是否有子目录 + child_count = db.query(MediaDirectory).filter( + MediaDirectory.parent_id == directory_id, + MediaDirectory.company_code == current_user.company_code + ).count() + + if child_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该文件夹下还有子文件夹,无法删除" + ) + + # 检查是否有媒体文件 + media_count = db.query(Media).filter( + Media.directory_id == directory_id, + Media.company_code == current_user.company_code + ).count() + + if media_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该文件夹下还有文件,无法删除" + ) + + db.delete(directory) + db.commit() + + return None + + +# ==================== 标签管理 ==================== + +@router.get("/tags", response_model=List[MediaTagResponse]) +async def get_tags( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取标签列表""" + tags = db.query(MediaTag).filter( + MediaTag.company_code == current_user.company_code + ).order_by(MediaTag.sort_order, MediaTag.created_at).all() + + return tags + + +@router.post("/tags", response_model=MediaTagResponse, status_code=status.HTTP_201_CREATED) +async def create_tag( + tag_data: MediaTagCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建标签""" + tag = MediaTag( + tag_name=tag_data.tag_name, + tag_color=tag_data.tag_color, + sort_order=tag_data.sort_order or 0, + company_code=current_user.company_code + ) + + db.add(tag) + db.commit() + db.refresh(tag) + + return tag + + +@router.put("/tags/{tag_id}", response_model=MediaTagResponse) +async def update_tag( + tag_id: int, + tag_update: MediaTagUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新标签""" + tag = db.query(MediaTag).filter( + MediaTag.id == tag_id, + MediaTag.company_code == current_user.company_code + ).first() + + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tag not found" + ) + + update_data = tag_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(tag, field, value) + + db.commit() + db.refresh(tag) + + return tag + + +@router.delete("/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_tag( + tag_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除标签""" + tag = db.query(MediaTag).filter( + MediaTag.id == tag_id, + MediaTag.company_code == current_user.company_code + ).first() + + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tag not found" + ) + + db.delete(tag) + db.commit() + + return None + + +@router.post("/tags/batch") +async def batch_add_tags( + batch_tag: MediaBatchTagRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """批量添加标签到素材""" + # 验证标签是否存在 + for tag_id in batch_tag.tag_ids: + tag = db.query(MediaTag).filter( + MediaTag.id == tag_id, + MediaTag.company_code == current_user.company_code + ).first() + + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Tag {tag_id} not found" + ) + + # 更新素材的标签 + media_list = db.query(Media).filter( + Media.media_id.in_(batch_tag.media_ids), + Media.company_code == current_user.company_code + ).all() + + if not media_list: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No media found" + ) + + for media in media_list: + current_tags = media.tags or [] + for tag_id in batch_tag.tag_ids: + if tag_id not in current_tags: + current_tags.append(tag_id) + media.tags = current_tags + + db.commit() + + return {"message": "Tags added successfully", "count": len(media_list)} + + +@router.delete("/tags/batch") +async def batch_remove_tags( + batch_tag: MediaBatchTagRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """批量移除素材的标签""" + media_list = db.query(Media).filter( + Media.media_id.in_(batch_tag.media_ids), + Media.company_code == current_user.company_code + ).all() + + if not media_list: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No media found" + ) + + for media in media_list: + current_tags = media.tags or [] + media.tags = [tag_id for tag_id in current_tags if tag_id not in batch_tag.tag_ids] + + db.commit() + + return {"message": "Tags removed successfully", "count": len(media_list)} + + +# ==================== 批量操作 ==================== + +@router.post("/batch", response_model=MediaBatchResponse) +async def batch_upload_media( + files: List[UploadFile] = File(...), + directory_id: Optional[int] = Form(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """批量上传媒体文件(每 5 张一批次)""" + logger.info(f"\n{'='*60}") + logger.info(f"🚀 接收到批量上传请求") + logger.info(f"{'='*60}") + logger.info(f"📊 文件数:{len(files)}") + logger.info(f"📊 directory_id: {directory_id}") + logger.info(f"👤 用户:{current_user.username}") + logger.info(f"🏢 公司编码:{current_user.company_code}") + logger.info(f"📧 邮箱:{current_user.email}") + logger.info(f"🆔 用户 ID: {current_user.id}") + + # 验证文件数量 + if len(files) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No files provided" + ) + + # 生成批次 ID + batch_id = generate_media_id() + logger.info(f"📦 生成批次 ID: {batch_id}") + + # 添加调试信息到响应 + debug_info = { + 'company_code': current_user.company_code, + 'username': current_user.username, + 'user_id': current_user.id + } + + # 验证并处理每个文件 + results = [] + successful = 0 + failed = 0 + + # 收集文件信息 + file_infos = [] + valid_files = [] + + for idx, file in enumerate(files, 1): + print(f"\n[{idx}/{len(files)}] 处理文件:{file.filename}") + + # 验证文件类型 + content_type = file.content_type or "" + print(f" - 文件类型:{content_type}") + + if not any(t in content_type for t in ['image', 'video']): + print(f" ❌ 文件类型不支持") + results.append({ + 'file_name': file.filename, + 'status': 'failed', + 'error': '仅支持图片和视频文件' + }) + failed += 1 + continue + + # 验证文件大小 + file_content = await file.read() + file_size = len(file_content) + print(f" - 文件大小:{file_size} 字节 ({file_size / 1024:.2f} KB)") + + if file_size > 5 * 1024 * 1024: + print(f" ❌ 文件大小超过限制") + results.append({ + 'file_name': file.filename, + 'status': 'failed', + 'error': '文件大小超过 5MB 限制' + }) + failed += 1 + continue + + # 生成文件名(MD5) + file_hash = hashlib.md5(file_content).hexdigest() + file_extension = os.path.splitext(file.filename)[1] + r2_key = f"{file_hash}{file_extension}" + print(f" - 文件哈希:{file_hash}") + print(f" - R2 Key: {r2_key}") + + # 生成media_id + media_id = generate_media_id() + + # 收集文件信息 + file_info = { + 'media_id': media_id, + 'file_name': file.filename, + 'file_content': file_content, + 'file_size': file_size, + 'content_type': content_type, + 'r2_key': r2_key, + 'directory_id': directory_id + } + + file_infos.append(file_info) + valid_files.append(file_info) + + # 批量检查数据库中是否存在相同文件 + existing_r2_keys = set() + existing_media_map = {} + + if valid_files: + # 提取所有r2_key + r2_keys = [f['r2_key'] for f in valid_files] + + # 批量查询数据库,只查询r2_url有值的记录 + existing_medias = db.query(Media).filter( + Media.r2_key.in_(r2_keys), + Media.r2_url.isnot(None), + Media.r2_url != '' + ).all() + + # 构建映射 + for media in existing_medias: + existing_r2_keys.add(media.r2_key) + existing_media_map[media.r2_key] = media + + print(f" 📊 数据库中已存在 {len(existing_r2_keys)} 个文件") + + # 按MD5分组文件,相同MD5的文件只上传一次到R2 + md5_groups = {} + + for file_info in valid_files: + r2_key = file_info['r2_key'] + + if r2_key in existing_r2_keys: + # 文件已存在,复用R2文件信息 + existing_media = existing_media_map[r2_key] + print(f" ⚠️ 文件已存在,复用R2文件但创建新记录") + print(f" - 复用R2 Key:{existing_media.r2_key}") + print(f" - 复用R2 URL:{existing_media.r2_url}") + print(f" - 目标目录:{file_info['directory_id']}") + + # 创建新的数据库记录,复用R2文件信息 + new_media = Media( + media_id=file_info['media_id'], + file_name=file_info['file_name'], + r2_key=existing_media.r2_key, + r2_url=existing_media.r2_url, + thumbnail_r2_key=existing_media.thumbnail_r2_key, + thumbnail_r2_url=existing_media.thumbnail_r2_url, + media_type=existing_media.media_type, + mime_type=existing_media.mime_type, + file_size=existing_media.file_size, + directory_id=file_info['directory_id'], + batch_id=batch_id, + status=MediaStatus.SUCCESS, + company_code=current_user.company_code + ) + + db.add(new_media) + db.commit() + db.refresh(new_media) + + print(f" ✅ 新记录创建成功 - media_id: {file_info['media_id']}") + + results.append({ + 'file_name': file_info['file_name'], + 'media_id': file_info['media_id'], + 'status': 'success', + 'r2_url': existing_media.r2_url, + 'thumbnail_r2_url': existing_media.thumbnail_r2_url, + 'info': '复用R2文件,创建新记录' + }) + successful += 1 + else: + # 文件不存在,添加到上传列表 + if r2_key not in md5_groups: + md5_groups[r2_key] = { + 'file_content': file_info['file_content'], + 'file_size': file_info['file_size'], + 'content_type': file_info['content_type'], + 'files': [] + } + + # 添加文件信息到分组 + md5_groups[r2_key]['files'].append({ + 'media_id': file_info['media_id'], + 'file_name': file_info['file_name'], + 'directory_id': file_info['directory_id'] + }) + + # 获取 R2 配置 + r2_config = get_r2_config(db, current_user.company_code) + + if not r2_config: + print(f" ❌ R2 配置未找到") + for r2_key, group in md5_groups.items(): + for file_info in group['files']: + results.append({ + 'file_name': file_info['file_name'], + 'status': 'failed', + 'error': 'R2 配置未找到' + }) + failed += 1 + else: + print(f" ✅ R2 配置已加载:{r2_config.bucket_name}") + + # 准备批量上传文件 + files_to_upload = [] + thumbnails_to_upload = [] + + for r2_key, group in md5_groups.items(): + # 添加到文件上传列表 + files_to_upload.append({ + 'file_buffer': group['file_content'], + 'file_name': r2_key, + 'content_type': group['content_type'] + }) + + # 如果是图片,添加到缩略图上传列表 + if 'image' in group['content_type']: + thumbnails_to_upload.append({ + 'file_buffer': group['file_content'], + 'file_name': f"thumb_{r2_key}", + 'width': 200, + 'height': 200 + }) + + # 批量上传文件 + upload_results = {} + thumbnail_results = {} + + if files_to_upload: + print(f" 📤 开始批量上传 {len(files_to_upload)} 个唯一文件到 R2...") + r2_uploader = R2Uploader(r2_config, company_code=current_user.company_code) + upload_results = await r2_uploader.batch_upload_files(files_to_upload, max_concurrency=5) + + # 批量上传缩略图 + if thumbnails_to_upload: + print(f" 🖼️ 开始批量上传 {len(thumbnails_to_upload)} 个缩略图到 R2...") + thumbnail_results = await r2_uploader.batch_upload_thumbnails(thumbnails_to_upload, max_concurrency=5) + + # 处理上传结果 + for r2_key, group in md5_groups.items(): + r2_url = upload_results.get(r2_key, '') + thumbnail_r2_url = thumbnail_results.get(f"thumb_{r2_key}", '') + + if r2_url: + # 为该MD5分组中的每个文件创建数据库记录 + for file_info in group['files']: + # 保存到数据库 + print(f" 💾 保存到数据库...") + media = Media( + media_id=file_info['media_id'], + file_name=file_info['file_name'], + r2_key=r2_key, + r2_url=r2_url, + thumbnail_r2_key=f"thumb_{r2_key}" if 'image' in group['content_type'] else None, + thumbnail_r2_url=thumbnail_r2_url if 'image' in group['content_type'] else None, + media_type='image' if 'image' in group['content_type'] else 'video', + mime_type=group['content_type'] or "", + file_size=group['file_size'], + directory_id=file_info['directory_id'], + batch_id=batch_id, + status=MediaStatus.SUCCESS, + company_code=current_user.company_code + ) + + db.add(media) + db.commit() + db.refresh(media) + + print(f" ✅ 数据库保存成功 - media_id: {file_info['media_id']}") + + results.append({ + 'file_name': file_info['file_name'], + 'media_id': file_info['media_id'], + 'status': 'success', + 'r2_url': r2_url, + 'thumbnail_r2_url': thumbnail_r2_url, + 'info': '复用R2文件,创建新记录' if len(group['files']) > 1 else '' + }) + successful += 1 + else: + # 上传失败,为该MD5分组中的每个文件添加失败记录并写入数据库 + for file_info in group['files']: + print(f" ❌ 文件上传失败:{file_info['file_name']}") + + # 创建失败的数据库记录,以便后续可以重新上传 + failed_media = Media( + media_id=file_info['media_id'], + file_name=file_info['file_name'], + r2_key=r2_key, + r2_url='', + thumbnail_r2_key=f"thumb_{r2_key}" if 'image' in group['content_type'] else None, + thumbnail_r2_url='', + media_type='image' if 'image' in group['content_type'] else 'video', + mime_type=group['content_type'] or "", + file_size=group['file_size'], + directory_id=file_info['directory_id'], + batch_id=batch_id, + status=MediaStatus.FAILED, + company_code=current_user.company_code + ) + + db.add(failed_media) + db.commit() + db.refresh(failed_media) + + print(f" 💾 失败记录已保存到数据库 - media_id: {file_info['media_id']}") + + results.append({ + 'file_name': file_info['file_name'], + 'media_id': file_info['media_id'], + 'status': 'failed', + 'error': '文件上传失败' + }) + failed += 1 + + logger.info(f"\n📊 批量上传完成 - 总计:{len(files)}, 成功:{successful}, 失败:{failed}") + + response_data = { + 'batch_id': batch_id, + 'total': len(files), + 'successful': successful, + 'failed': failed, + 'results': results, + 'debug': debug_info + } + + logger.info(f"📥 返回响应:{response_data}") + + return response_data + + +@router.post("/batch/delete", status_code=status.HTTP_204_NO_CONTENT) +async def batch_delete_media( + batch_delete: MediaBatchDeleteRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """批量删除媒体素材(软删除)""" + media_list = db.query(Media).filter( + Media.media_id.in_(batch_delete.media_ids), + Media.company_code == current_user.company_code, + Media.is_deleted == False + ).all() + + if not media_list: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No media found" + ) + + print(f"\n📤 批量软删除 {len(media_list)} 个媒体文件") + + now = datetime.now() + for idx, media in enumerate(media_list, 1): + print(f"\n [{idx}/{len(media_list)}] 软删除: {media.file_name}") + print(f" - R2 Key: {media.r2_key}") + + # 软删除:标记为已删除,不删除R2文件 + media.is_deleted = True + media.deleted_at = now + + db.commit() + print(f"\n✅ 批量软删除完成\n") + + return None + + +@router.post("/batch/update", response_model=List[MediaResponse]) +async def batch_update_media( + batch_update: MediaBatchUpdateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """批量更新媒体素材(设置目录、标签)""" + media_list = db.query(Media).filter( + Media.media_id.in_(batch_update.media_ids), + Media.company_code == current_user.company_code + ).all() + + if not media_list: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No media found" + ) + + if batch_update.directory_id is not None: + if batch_update.directory_id != 0: + directory = db.query(MediaDirectory).filter( + MediaDirectory.id == batch_update.directory_id, + MediaDirectory.company_code == current_user.company_code + ).first() + + if not directory: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Directory not found" + ) + + for media in media_list: + if batch_update.directory_id is not None: + media.directory_id = batch_update.directory_id if batch_update.directory_id > 0 else None + + if batch_update.tags is not None: + media.tags = batch_update.tags + + db.commit() + + for media in media_list: + db.refresh(media) + + return media_list + + +# ==================== 基础媒体操作(最后排,避免与其他路由冲突) ==================== + +@router.get("/", response_model=dict) +async def get_media_list( + page: int = 1, + page_size: int = 20, + media_type: Optional[str] = None, + status: Optional[str] = None, + directory_id: Optional[int] = None, + keyword: Optional[str] = None, + tag_ids: Optional[List[int]] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + min_size: Optional[int] = None, + max_size: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取媒体素材列表(支持搜索筛选)""" + query = db.query(Media).filter( + Media.company_code == current_user.company_code, + Media.is_deleted == False + ) + + if keyword: + query = query.filter( + Media.file_name.contains(keyword) | + Media.media_id.contains(keyword) + ) + + if media_type: + query = query.filter(Media.media_type == media_type) + + if status: + query = query.filter(Media.status == status) + + if directory_id is not None: + query = query.filter(Media.directory_id == directory_id) + + if tag_ids: + query = query.filter( + Media.tags.contains(tag_ids) + ) + + if start_date: + query = query.filter(Media.created_at >= start_date) + + if end_date: + query = query.filter(Media.created_at <= end_date) + + if min_size is not None: + query = query.filter(Media.file_size >= min_size) + + if max_size is not None: + query = query.filter(Media.file_size <= max_size) + + total = query.count() + + # 计算 skip + skip = (page - 1) * page_size + media_list = query.offset(skip).limit(page_size).all() + + # 转换为 MediaResponse 对象 + items = [] + for media in media_list: + item = MediaResponse( + id=media.id, + media_id=media.media_id, + file_name=media.file_name, + file_path="", + file_size=media.file_size, + media_type=media.media_type, + content_type=media.mime_type, + r2_key=media.r2_key, + r2_url=media.r2_url, + thumbnail_r2_key=media.thumbnail_r2_key, + thumbnail_r2_url=media.thumbnail_r2_url, + width=media.width, + height=media.height, + duration=media.duration, + status=media.status, + error_message=media.error_message, + directory_id=media.directory_id, + tags=media.tags or [], + metadata=media.metadata or {}, + company_code=media.company_code, + created_at=media.created_at, + updated_at=media.updated_at + ) + items.append(item) + + return { + 'items': items, + 'total': total, + 'page': page, + 'page_size': page_size + } + + +@router.get("/{media_id}", response_model=MediaResponse) +async def get_media( + media_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取单个媒体素材""" + media = db.query(Media).filter( + Media.media_id == media_id, + Media.company_code == current_user.company_code, + Media.is_deleted == False + ).first() + + if not media: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Media not found" + ) + + return media + + +@router.post("/", response_model=MediaResponse, status_code=status.HTTP_201_CREATED) +async def create_media( + file: UploadFile = File(...), + directory_id: Optional[int] = Form(None), + description: Optional[str] = Form(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """上传单个媒体文件""" + # 验证文件类型 + allowed_types = ['image', 'video'] + content_type = file.content_type or "" + + if not any(t in content_type for t in allowed_types): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only image and video files are allowed" + ) + + # 验证文件大小(最大5MB) + file_size = 0 + file_content = await file.read() + file_size = len(file_content) + + if file_size > 5 * 1024 * 1024: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File size exceeds 5MB limit" + ) + + # 生成media_id + media_id = generate_media_id() + + # 生成文件名(MD5) + file_hash = hashlib.md5(file_content).hexdigest() + file_extension = os.path.splitext(file.filename)[1] + r2_key = f"{file_hash}{file_extension}" + + # 获取R2配置 + r2_config = get_r2_config(db, current_user.company_code) + + if not r2_config: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="R2 configuration not found" + ) + + # 上传到R2 + try: + r2_uploader = R2Uploader(r2_config) + + # 上传原图 + file_buffer = file_content + r2_url = await r2_uploader.upload_file( + file_buffer=file_buffer, + file_name=r2_key, + content_type=content_type + ) + + # 生成缩略图(如果是图片) + thumbnail_r2_key = None + thumbnail_r2_url = None + + if 'image' in content_type: + thumbnail_r2_key = f"thumb_{r2_key}" + thumbnail_r2_url = await r2_uploader.upload_thumbnail( + file_buffer=file_buffer, + file_name=thumbnail_r2_key, + width=200, + height=200 + ) + + # 保存到数据库 + media = Media( + media_id=media_id, + file_name=file.filename, + r2_key=r2_key, + r2_url=r2_url, + thumbnail_r2_key=thumbnail_r2_key, + thumbnail_r2_url=thumbnail_r2_url, + media_type='image' if 'image' in content_type else 'video', + mime_type=file.content_type or "", + file_size=file_size, + directory_id=directory_id, + status=MediaStatus.SUCCESS, + company_code=current_user.company_code + ) + + db.add(media) + db.commit() + db.refresh(media) + + return media + + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Upload failed: {str(e)}" + ) + + +@router.put("/{media_id}", response_model=MediaResponse) +async def update_media( + media_id: str, + media_update: MediaUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新媒体素材""" + media = db.query(Media).filter( + Media.media_id == media_id, + Media.company_code == current_user.company_code + ).first() + + if not media: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Media not found" + ) + + update_data = media_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(media, field, value) + + db.commit() + db.refresh(media) + + return media + + +@router.delete("/{media_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_media( + media_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除媒体素材(软删除)""" + media = db.query(Media).filter( + Media.media_id == media_id, + Media.company_code == current_user.company_code, + Media.is_deleted == False + ).first() + + if not media: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Media not found" + ) + + print(f"\n📤 软删除媒体: {media.file_name} (ID: {media_id})") + print(f" - R2 Key: {media.r2_key}") + + # 软删除:标记为已删除,不删除R2文件 + media.is_deleted = True + media.deleted_at = datetime.now() + + db.commit() + print(f" ✅ 软删除完成\n") + + return None diff --git a/routers/member_level.py b/routers/member_level.py new file mode 100644 index 0000000000000000000000000000000000000000..cfc776e09535da4c83d63f4daff0a3ae05acc00d --- /dev/null +++ b/routers/member_level.py @@ -0,0 +1,513 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional +import json + +from database import get_db +from models.member_level import MemberLevel +from models.user import User +from models.membership import Membership +from models.product import Product +from models.product_media import ProductMedia +from models.media import Media +from schemas.member_level import MemberLevelResponse, MemberLevelCreate, MemberLevelUpdate, PublicMemberLevelResponse +from schemas.operation_log import OperationLogCreate +from routers.auth import get_current_user, get_current_superadmin +from routers.operation_log import create_operation_log +from sqlalchemy import func +from config.member_levels import DEFAULT_MEMBER_LEVELS + +router = APIRouter() + +# 单位转换常量 +BYTES_PER_KB = 1024 +BYTES_PER_MB = 1024 * 1024 +BYTES_PER_GB = 1024 * 1024 * 1024 + +# 计算会员等级的权益条目数 +def calculate_benefit_count(level): + """计算一个会员等级的权益条目数量""" + # 数值类型的权益字段 + numeric_benefits = [ + 'deployment_nodes', + 'product_sku_limit', + 'image_storage_limit', + 'sku_image_limit', + 'sku_video_limit' + ] + + # 布尔类型的权益字段 + boolean_benefits = [ + 'template_access', + 'geo_seo_access', + 'social_automation_access', + 'custom_development_access' + ] + + count = 0 + + # 统计数值类型权益 + for field in numeric_benefits: + if level.get(field, 0) > 0: + count += 1 + + # 统计布尔类型权益 + for field in boolean_benefits: + if level.get(field, False): + count += 1 + + return count + +# 获取会员等级列表(公开接口,无需认证) +@router.get("/public", response_model=PublicMemberLevelResponse) +async def get_public_member_levels( + db: Session = Depends(get_db) +): + """公开的会员等级列表接口,用于首页展示""" + member_levels = db.query(MemberLevel).order_by(MemberLevel.level).all() + + # 如果数据库中没有数据,使用默认数据 + if not member_levels: + member_levels = DEFAULT_MEMBER_LEVELS + else: + # 转换为字典格式 + member_levels = [ + { + "level": level.level, + "name": level.name, + "description": level.description, + "annual_fee": level.annual_fee, + "discount_price": level.discount_price, + "deployment_nodes": level.deployment_nodes, + "email_marketing_limit": level.email_marketing_limit, + "product_sku_limit": level.product_sku_limit, + "sku_image_limit": level.sku_image_limit, + "image_size_limit": level.image_size_limit, + "sku_video_limit": level.sku_video_limit, + "image_storage_limit": level.image_storage_limit, + "template_access": level.template_access, + "geo_seo_access": level.geo_seo_access, + "social_automation_access": level.social_automation_access, + "custom_development_access": level.custom_development_access + } + for level in member_levels + ] + + # 计算最大权益条目数(最高等级的权益必定最多,只需计算最后一个) + max_count = calculate_benefit_count(member_levels[-1]) if member_levels else 0 + + return PublicMemberLevelResponse( + member_levels=member_levels, + max_benefit_count=max_count + ) + +# 获取会员等级列表 +@router.get("/", response_model=List[MemberLevelResponse]) +async def get_member_levels( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + member_levels = db.query(MemberLevel).order_by(MemberLevel.level).all() + return member_levels + +# 通过ID获取会员等级详情 +@router.get("/{level_id}", response_model=MemberLevelResponse) +async def get_member_level( + level_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + member_level = db.query(MemberLevel).filter(MemberLevel.id == level_id).first() + if member_level is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Member level with ID '{level_id}' not found" + ) + + return member_level + +# 创建会员等级(仅超级管理员可操作) +@router.post("/", response_model=MemberLevelResponse) +async def create_member_level( + member_level: MemberLevelCreate, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + # 检查等级是否已存在 + existing_level = db.query(MemberLevel).filter(MemberLevel.level == member_level.level).first() + if existing_level: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Member level with level '{member_level.level}' already exists" + ) + + # 直接使用模型数据创建会员等级 + level_data = member_level.model_dump() + db_level = MemberLevel(**level_data) + db.add(db_level) + db.commit() + db.refresh(db_level) + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="member_level", + operation_type="add", + operation_detail=f"新增会员等级:{db_level.name}(等级:{db_level.level})", + company_code=current_user.company_code + ) + + return db_level + +# 更新会员等级(仅超级管理员可操作) +@router.put("/{level_id}", response_model=MemberLevelResponse) +async def update_member_level( + level_id: int, + member_level_update: MemberLevelUpdate, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + member_level = db.query(MemberLevel).filter(MemberLevel.id == level_id).first() + if member_level is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Member level with ID '{level_id}' not found" + ) + + # 记录修改前的数据 + before_data_dict = { + "name": member_level.name, + "description": member_level.description, + "annual_fee": member_level.annual_fee, + "discount_price": member_level.discount_price, + "deployment_nodes": member_level.deployment_nodes, + "email_marketing_limit": member_level.email_marketing_limit, + "product_sku_limit": member_level.product_sku_limit, + "sku_image_limit": member_level.sku_image_limit, + "image_size_limit": member_level.image_size_limit, + "sku_video_limit": member_level.sku_video_limit, + "image_storage_limit": member_level.image_storage_limit, + "template_access": member_level.template_access, + "geo_seo_access": member_level.geo_seo_access, + "social_automation_access": member_level.social_automation_access, + "custom_development_access": member_level.custom_development_access + } + + # 更新会员等级信息 + update_data = member_level_update.model_dump(exclude_unset=True) + + for field, value in update_data.items(): + setattr(member_level, field, value) + + db.commit() + db.refresh(member_level) + + # 记录修改后的数据 + after_data_dict = { + "name": member_level.name, + "description": member_level.description, + "annual_fee": member_level.annual_fee, + "discount_price": member_level.discount_price, + "deployment_nodes": member_level.deployment_nodes, + "email_marketing_limit": member_level.email_marketing_limit, + "product_sku_limit": member_level.product_sku_limit, + "sku_image_limit": member_level.sku_image_limit, + "image_size_limit": member_level.image_size_limit, + "sku_video_limit": member_level.sku_video_limit, + "image_storage_limit": member_level.image_storage_limit, + "template_access": member_level.template_access, + "geo_seo_access": member_level.geo_seo_access, + "social_automation_access": member_level.social_automation_access, + "custom_development_access": member_level.custom_development_access + } + + # 只记录有修改的字段,使用中文解析 + modified_fields = [] + field_names = { + "name": "等级名称", + "description": "等级描述", + "annual_fee": "会员每年权益服务费", + "discount_price": "当前优惠价", + "deployment_nodes": "独立站部署节点数", + "email_marketing_limit": "邮件营销推送数量", + "product_sku_limit": "产品SKU数", + "sku_image_limit": "每个SKU可加图片数量", + "image_size_limit": "图片大小限制", + "sku_video_limit": "每个SKU可加视频数量", + "image_storage_limit": "图片空间大小", + "template_access": "模板选择权限", + "geo_seo_access": "GEO,SEO关键词营销", + "social_automation_access": "社交平台自动化营销", + "custom_development_access": "定制化需求开发" + } + + for field, chinese_name in field_names.items(): + if before_data_dict[field] != after_data_dict[field]: + modified_fields.append(f"{chinese_name}:{before_data_dict[field]} → {after_data_dict[field]}") + + # 生成中文描述的修改内容 + if modified_fields: + modification_detail = ";".join(modified_fields) + else: + modification_detail = "无字段修改" + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="member_level", + operation_type="edit", + operation_detail=f"编辑会员等级:{member_level.name}(等级:{member_level.level})", + before_data=modification_detail, + after_data=None, + company_code=current_user.company_code + ) + + return member_level + +# 删除会员等级(仅超级管理员可操作) +@router.delete("/{level_id}") +async def delete_member_level( + level_id: int, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + member_level = db.query(MemberLevel).filter(MemberLevel.id == level_id).first() + if member_level is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Member level with ID '{level_id}' not found" + ) + + # 记录会员等级名称和等级,用于日志 + level_name = member_level.name + level = member_level.level + + db.delete(member_level) + db.commit() + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="member_level", + operation_type="delete", + operation_detail=f"删除会员等级:{level_name}(等级:{level})", + company_code=current_user.company_code + ) + + return {"message": f"Member level with ID '{level_id}' deleted successfully"} + +# 获取当前用户会员权益使用情况 +@router.get("/my/benefits") +async def get_my_member_benefits( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取当前用户的会员权益及使用情况""" + # 获取用户会员等级 + membership = db.query(Membership).filter( + Membership.company_code == current_user.company_code + ).order_by(Membership.end_date.desc()).first() + + if not membership: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="未找到会员信息" + ) + + # 获取会员等级详情 + member_level = db.query(MemberLevel).filter(MemberLevel.level == membership.level).first() + if not member_level: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="未找到会员等级配置" + ) + + # 统计数据 + # 1. 产品SKU使用数量 + product_count = db.query(Product).filter( + Product.company_code == current_user.company_code + ).count() + + # 2. 图片空间使用情况 (Media模型的file_size是字节) + total_image_size_bytes = db.query(func.sum(Media.file_size)).filter( + Media.company_code == current_user.company_code, + Media.media_type == 'image' + ).scalar() or 0 + + # 直接转换为GB (字节 -> GB) + total_image_size_gb = round(total_image_size_bytes / BYTES_PER_GB, 2) + + # 3. 每个SKU平均图片数量 + product_media_count = db.query( + ProductMedia.product_id, + func.count(ProductMedia.id).label('media_count') + ).filter( + ProductMedia.company_code == current_user.company_code, + ProductMedia.media_type == 'image' + ).group_by(ProductMedia.product_id).all() + + avg_images_per_sku = round( + sum([pm.media_count for pm in product_media_count]) / len(product_media_count), + 1 + ) if product_media_count else 0 + + # 4. 视频数量统计 + video_count = db.query(ProductMedia).filter( + ProductMedia.company_code == current_user.company_code, + ProductMedia.media_type == 'video' + ).count() + + # 计算每个产品的平均视频数 + avg_videos_per_sku = round(video_count / product_count, 1) if product_count > 0 else 0 + + # 5. 邮件营销使用数量(这里暂时返回0,需要邮件模块实现后更新) + email_used = 0 + + # 计算会员到期剩余天数 + from datetime import datetime + days_remaining = 0 + if membership.end_date: + delta = membership.end_date - datetime.now().date() + days_remaining = max(0, delta.days) + + # 计算各权益的使用率指数 (0-100) + def calculate_usage_index(used, total): + if not total or total == 0: + return 0 + return min(round((used / total) * 100, 1), 100) + + benefits = { + "level": membership.level, + "level_name": member_level.name, + "start_date": membership.start_date, + "end_date": membership.end_date, + "days_remaining": days_remaining, + "items": [ + { + "name": "独立站部署节点", + "icon": "🌐", + "used": 1, + "total": member_level.deployment_nodes or 0, + "unit": "个", + "usage_index": calculate_usage_index(1, member_level.deployment_nodes or 0), + "description": "独立网站部署节点数量" + }, + { + "name": "产品SKU数量", + "icon": "📦", + "used": product_count, + "total": member_level.product_sku_limit or 0, + "unit": "个", + "usage_index": calculate_usage_index(product_count, member_level.product_sku_limit or 0), + "description": "已使用产品SKU数量" + }, + { + "name": "邮件营销推送", + "icon": "📧", + "used": email_used, + "total": member_level.email_marketing_limit or 0, + "unit": "封", + "usage_index": calculate_usage_index(email_used, member_level.email_marketing_limit or 0), + "description": "邮件营销推送数量" + }, + { + "name": "图片存储空间", + "icon": "🖼️", + "used": total_image_size_gb, + "total": member_level.image_storage_limit or 0, + "unit": "GB", + "usage_index": calculate_usage_index(total_image_size_gb, member_level.image_storage_limit or 0), + "description": "已使用图片存储空间" + }, + { + "name": "每SKU图片", + "icon": "📸", + "used": avg_images_per_sku, + "total": member_level.sku_image_limit or 0, + "unit": "张", + "usage_index": calculate_usage_index(avg_images_per_sku, member_level.sku_image_limit or 0), + "description": "每个SKU平均图片数量" + }, + { + "name": "每SKU视频", + "icon": "🎬", + "used": avg_videos_per_sku, + "total": member_level.sku_video_limit or 0, + "unit": "个", + "usage_index": calculate_usage_index(avg_videos_per_sku, member_level.sku_video_limit or 0), + "description": "每个SKU平均视频数量" + }, + { + "name": "模板选择权限", + "icon": "🎨", + "used": 1 if member_level.template_access else 0, + "total": 1, + "unit": "", + "usage_index": 100 if member_level.template_access else 0, + "description": "是否可访问模板库", + "is_boolean": True + }, + { + "name": "GEO/SEO关键词营销", + "icon": "🔍", + "used": 1 if member_level.geo_seo_access else 0, + "total": 1, + "unit": "", + "usage_index": 100 if member_level.geo_seo_access else 0, + "description": "是否支持SEO/ GEO营销", + "is_boolean": True + }, + { + "name": "社交平台自动化营销", + "icon": "📱", + "used": 1 if member_level.social_automation_access else 0, + "total": 1, + "unit": "", + "usage_index": 100 if member_level.social_automation_access else 0, + "description": "是否支持社交媒体自动化", + "is_boolean": True + }, + { + "name": "定制化需求开发", + "icon": "🛠️", + "used": 1 if member_level.custom_development_access else 0, + "total": 1, + "unit": "", + "usage_index": 100 if member_level.custom_development_access else 0, + "description": "是否支持定制化开发", + "is_boolean": True + } + ], + "summary": { + "total_benefits": 10, + "enabled_benefits": sum([ + 1, # 部署节点 + 1, # SKU + 1, # 邮件 + 1, # 图片存储 + 1, # SKU图片 + 1, # SKU视频 + 1 if member_level.template_access else 0, + 1 if member_level.geo_seo_access else 0, + 1 if member_level.social_automation_access else 0, + 1 if member_level.custom_development_access else 0 + ]), + "overall_usage_index": round(sum([ + calculate_usage_index(1, member_level.deployment_nodes or 0), + calculate_usage_index(product_count, member_level.product_sku_limit or 0), + calculate_usage_index(email_used, member_level.email_marketing_limit or 0), + calculate_usage_index(total_image_size_gb, member_level.image_storage_limit or 0), + calculate_usage_index(avg_images_per_sku, member_level.sku_image_limit or 0), + calculate_usage_index(avg_videos_per_sku, member_level.sku_video_limit or 0), + 100 if member_level.template_access else 0, + 100 if member_level.geo_seo_access else 0, + 100 if member_level.social_automation_access else 0, + 100 if member_level.custom_development_access else 0 + ]) / 10, 1) + } + } + + return benefits \ No newline at end of file diff --git a/routers/membership.py b/routers/membership.py new file mode 100644 index 0000000000000000000000000000000000000000..660483da212244094e45a5ba29dd116056e10471 --- /dev/null +++ b/routers/membership.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models.membership import Membership +from models.user import User +from schemas.membership import MembershipResponse, MembershipCreate, MembershipUpdate +from routers.auth import get_current_user, get_current_superadmin + +router = APIRouter() + +# 获取会员充值记录列表 +@router.get("/", response_model=List[MembershipResponse]) +async def get_membership_records( + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # 普通用户只能查看自己公司的记录 + if not current_user.is_superadmin: + memberships = db.query(Membership).filter( + Membership.company_code == current_user.company_code + ).offset(skip).limit(limit).all() + else: + # 超级管理员可以查看所有记录 + memberships = db.query(Membership).offset(skip).limit(limit).all() + return memberships + +# 通过公司编码获取会员充值记录 +@router.get("/{company_code}", response_model=List[MembershipResponse]) +async def get_membership_by_company( + company_code: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # 检查用户权限:超级管理员可以访问所有公司,普通用户只能访问自己公司 + if not current_user.is_superadmin and current_user.company_code != company_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to access this company's membership records" + ) + + memberships = db.query(Membership).filter(Membership.company_code == company_code).all() + return memberships + +# 创建会员充值记录(仅超级管理员可操作) +@router.post("/", response_model=MembershipResponse) +async def create_membership( + membership: MembershipCreate, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + db_membership = Membership(**membership.model_dump()) + db.add(db_membership) + db.commit() + db.refresh(db_membership) + return db_membership + +# 更新会员充值记录(仅超级管理员可操作) +@router.put("/{membership_id}", response_model=MembershipResponse) +async def update_membership( + membership_id: int, + membership_update: MembershipUpdate, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + db_membership = db.query(Membership).filter(Membership.id == membership_id).first() + if db_membership is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Membership record with id {membership_id} not found" + ) + + # 更新会员充值记录 + update_data = membership_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_membership, field, value) + + db.commit() + db.refresh(db_membership) + return db_membership + +# 删除会员充值记录(仅超级管理员可操作) +@router.delete("/{membership_id}") +async def delete_membership( + membership_id: int, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + db_membership = db.query(Membership).filter(Membership.id == membership_id).first() + if db_membership is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Membership record with id {membership_id} not found" + ) + + db.delete(db_membership) + db.commit() + return {"message": f"Membership record with id {membership_id} deleted successfully"} \ No newline at end of file diff --git a/routers/operation_log.py b/routers/operation_log.py new file mode 100644 index 0000000000000000000000000000000000000000..c19b5db8f80d2812c5e65502daf7ea52ad6409e7 --- /dev/null +++ b/routers/operation_log.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models.operation_log import OperationLog +from models.user import User +from schemas.operation_log import OperationLogResponse, OperationLogCreate +from routers.auth import get_current_user, get_current_superadmin, is_admin_user + +router = APIRouter() + +# 记录操作日志的辅助函数 +def create_operation_log(db: Session, user: User, module: str, operation_type: str, operation_detail: str = None, before_data: str = None, after_data: str = None, company_code: str = None): + """创建操作日志""" + log = OperationLog( + user_id=user.id, + username=user.username, + company_code=company_code, + module=module, + operation_type=operation_type, + operation_detail=operation_detail, + before_data=before_data, + after_data=after_data + ) + db.add(log) + db.commit() + db.refresh(log) + return log + +# 获取操作日志列表 +@router.get("/", response_model=List[OperationLogResponse]) +async def get_operation_logs( + module: str = None, + operation_type: str = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取操作日志列表,支持按模块和操作类型过滤""" + query = db.query(OperationLog) + + # 数据权限分级:普通用户只能看到自己公司的日志,admin和超级管理员可以看到所有日志 + if not is_admin_user(current_user) and not current_user.is_superadmin and current_user.company_code: + query = query.filter(OperationLog.company_code == current_user.company_code) + + if module: + query = query.filter(OperationLog.module == module) + if operation_type: + query = query.filter(OperationLog.operation_type == operation_type) + + # 只返回前10条,并按照日期倒序排列 + logs = query.order_by(OperationLog.created_at.desc()).limit(10).all() + return logs + +# 获取指定模块的操作日志 +@router.get("/module/{module}", response_model=List[OperationLogResponse]) +async def get_operation_logs_by_module( + module: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取指定模块的操作日志""" + query = db.query(OperationLog).filter( + OperationLog.module == module + ) + + # 数据权限分级:普通用户只能看到自己公司的日志,admin和超级管理员可以看到所有日志 + if not is_admin_user(current_user) and not current_user.is_superadmin and current_user.company_code: + query = query.filter(OperationLog.company_code == current_user.company_code) + + # 只返回前10条,并按照日期倒序排列 + logs = query.order_by(OperationLog.created_at.desc()).limit(10).all() + return logs diff --git a/routers/product_certifications.py b/routers/product_certifications.py new file mode 100644 index 0000000000000000000000000000000000000000..1a7a8deee4d05cd016fab8f7c25556c868ebc3ef --- /dev/null +++ b/routers/product_certifications.py @@ -0,0 +1,170 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from sqlalchemy.orm import Session +from typing import List, Optional + +from database import get_db +from models.product_certification import ProductCertification +from models.user import User +from schemas.product import ( + ProductCertificationResponse, + ProductCertificationCreate, + ProductCertificationUpdate +) +from routers.auth import get_current_user +from routers.product_utils import validate_product_exists, get_company_r2_config, delete_r2_and_local_files +from config.certification_types import DEFAULT_CERTIFICATION_TYPES + +# 导入日志模块 +from utils.logger import logger + +router = APIRouter() + + +@router.get("/certification-types") +async def get_certification_types(): + """获取商品认证类型列表""" + logger.info("获取商品认证类型列表") + return DEFAULT_CERTIFICATION_TYPES + + +@router.get("/{product_number}/certifications", response_model=List[ProductCertificationResponse]) +async def read_product_certifications( + product_number: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取商品认证列表""" + logger.info(f"获取商品认证列表 - product_number: {product_number}, user: {current_user.username}") + from routers.auth import is_admin_user + + query = db.query(ProductCertification).filter(ProductCertification.product_number == product_number) + + if not is_admin_user(current_user): + query = query.filter(ProductCertification.company_code == current_user.company_code) + + certifications = query.all() + logger.info(f"获取商品认证列表成功 - product_number: {product_number}, 共 {len(certifications)} 个认证") + return certifications + + +@router.post("/{product_number}/certifications", response_model=ProductCertificationResponse, status_code=status.HTTP_201_CREATED) +async def create_product_certification( + product_number: str, + certification_type: str = Form(...), + file_url: str = Form(...), + thumbnail_url: Optional[str] = Form(None), + media_id: str = Form(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建商品认证(通过素材选择器方式)""" + logger.info(f"创建商品认证 - product_number: {product_number}, certification_type: {certification_type}, user: {current_user.username}") + product = validate_product_exists(product_number, current_user, db) + + cert_data = { + 'product_number': product_number, + 'product_id': product.id, + 'company_code': current_user.company_code, + 'certification_type': certification_type, + 'r2_url': file_url, + 'thumbnail_r2_url': thumbnail_url or file_url, + 'media_id': media_id + } + + cert = ProductCertification(**cert_data) + db.add(cert) + db.commit() + db.refresh(cert) + + logger.info(f"创建商品认证成功 - certification_id: {cert.id}, product_number: {product_number}") + return cert + + +@router.post("/{product_number}/certifications/batch", response_model=List[ProductCertificationResponse], status_code=status.HTTP_201_CREATED) +async def batch_create_product_certification( + product_number: str, + cert_items: List[dict], + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """批量创建商品认证""" + logger.info(f"批量创建商品认证 - product_number: {product_number}, 数量: {len(cert_items)}, user: {current_user.username}") + product = validate_product_exists(product_number, current_user, db) + + created_certs = [] + for item in cert_items: + cert_data = { + 'product_number': product_number, + 'product_id': product.id, + 'company_code': current_user.company_code, + 'certification_type': item.get('certification_type'), + 'r2_url': item.get('file_url'), + 'thumbnail_r2_url': item.get('thumbnail_url') or item.get('file_url'), + 'media_id': item.get('media_id') + } + + cert = ProductCertification(**cert_data) + db.add(cert) + created_certs.append(cert) + + db.commit() + for cert in created_certs: + db.refresh(cert) + + logger.info(f"批量创建商品认证成功 - product_number: {product_number}, 成功创建 {len(created_certs)} 个认证") + return created_certs + + +@router.put("/certifications/{certification_id}", response_model=ProductCertificationResponse) +async def update_product_certification( + certification_id: int, + cert_update: ProductCertificationUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新商品认证""" + logger.info(f"更新商品认证 - certification_id: {certification_id}, user: {current_user.username}") + from routers.auth import is_admin_user + + query = db.query(ProductCertification).filter(ProductCertification.id == certification_id) + + if not is_admin_user(current_user): + query = query.filter(ProductCertification.company_code == current_user.company_code) + + certification = query.first() + if certification is None: + logger.error(f"认证不存在 - certification_id: {certification_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certification not found") + update_data = cert_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(certification, field, value) + db.commit() + db.refresh(certification) + logger.info(f"更新商品认证成功 - certification_id: {certification_id}") + return certification + + +@router.delete("/certifications/{certification_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_product_certification( + certification_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除商品认证""" + logger.info(f"删除商品认证 - certification_id: {certification_id}, user: {current_user.username}") + from routers.auth import is_admin_user + + query = db.query(ProductCertification).filter(ProductCertification.id == certification_id) + + if not is_admin_user(current_user): + query = query.filter(ProductCertification.company_code == current_user.company_code) + + certification = query.first() + if certification is None: + logger.error(f"认证不存在 - certification_id: {certification_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certification not found") + + db.delete(certification) + db.commit() + logger.info(f"删除商品认证成功 - certification_id: {certification_id}") + return None diff --git a/routers/product_media.py b/routers/product_media.py new file mode 100644 index 0000000000000000000000000000000000000000..6a4f5ca1d622dc8fc6bff7c6e37f6ebd96de6660 --- /dev/null +++ b/routers/product_media.py @@ -0,0 +1,171 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from sqlalchemy.orm import Session +from typing import List, Optional + +from database import get_db +from models.product_media import ProductMedia +from models.user import User +from schemas.product import ( + ProductMediaResponse, + ProductMediaCreate, + ProductMediaUpdate +) +from routers.auth import get_current_user +from routers.product_utils import validate_product_exists, get_company_r2_config, delete_r2_and_local_files + +# 导入日志模块 +from utils.logger import logger + +router = APIRouter() + + +@router.get("/{product_number}/media", response_model=List[ProductMediaResponse]) +async def read_product_media( + product_number: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取商品媒体列表""" + logger.info(f"获取商品媒体列表 - product_number: {product_number}, user: {current_user.username}") + from routers.auth import is_admin_user + + query = db.query(ProductMedia).filter(ProductMedia.product_number == product_number) + + if not is_admin_user(current_user): + query = query.filter(ProductMedia.company_code == current_user.company_code) + + media = query.order_by(ProductMedia.sort_order).all() + logger.info(f"获取商品媒体列表成功 - product_number: {product_number}, 共 {len(media)} 个媒体") + return media + + +@router.post("/{product_number}/media", response_model=ProductMediaResponse, status_code=status.HTTP_201_CREATED) +async def create_product_media( + product_number: str, + media_type: str = Form(...), + media_alt: Optional[str] = Form(None), + is_main: Optional[int] = Form(0), + sort_order: Optional[int] = Form(0), + media_url: str = Form(...), + thumbnail_url: Optional[str] = Form(None), + media_id: str = Form(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建商品媒体(通过素材选择器方式)""" + logger.info(f"创建商品媒体 - product_number: {product_number}, media_type: {media_type}, user: {current_user.username}") + product = validate_product_exists(product_number, current_user, db) + + media_data = { + 'product_number': product_number, + 'product_id': product.id, + 'company_code': current_user.company_code, + 'media_type': media_type, + 'r2_url': media_url, + 'thumbnail_r2_url': thumbnail_url or media_url, + 'media_id': media_id, + 'media_alt': media_alt or '', + 'is_main': is_main, + 'sort_order': sort_order + } + + media = ProductMedia(**media_data) + db.add(media) + db.commit() + db.refresh(media) + + logger.info(f"创建商品媒体成功 - media_id: {media.id}, product_number: {product_number}") + return media + + +@router.post("/{product_number}/media/batch", response_model=List[ProductMediaResponse], status_code=status.HTTP_201_CREATED) +async def batch_create_product_media( + product_number: str, + media_items: List[dict], + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """批量创建商品媒体""" + logger.info(f"批量创建商品媒体 - product_number: {product_number}, 数量: {len(media_items)}, user: {current_user.username}") + product = validate_product_exists(product_number, current_user, db) + + created_media = [] + for item in media_items: + media_data = { + 'product_number': product_number, + 'product_id': product.id, + 'company_code': current_user.company_code, + 'media_type': item.get('media_type', 'image'), + 'r2_url': item.get('media_url'), + 'thumbnail_r2_url': item.get('thumbnail_url') or item.get('media_url'), + 'media_id': item.get('media_id'), + 'media_alt': item.get('media_alt', ''), + 'is_main': item.get('is_main', 0), + 'sort_order': item.get('sort_order', 0) + } + + media = ProductMedia(**media_data) + db.add(media) + created_media.append(media) + + db.commit() + for media in created_media: + db.refresh(media) + + logger.info(f"批量创建商品媒体成功 - product_number: {product_number}, 成功创建 {len(created_media)} 个媒体") + return created_media + + +@router.put("/media/{media_id}", response_model=ProductMediaResponse) +async def update_product_media( + media_id: int, + media_update: ProductMediaUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新商品媒体""" + logger.info(f"更新商品媒体 - media_id: {media_id}, user: {current_user.username}") + from routers.auth import is_admin_user + + query = db.query(ProductMedia).filter(ProductMedia.id == media_id) + + if not is_admin_user(current_user): + query = query.filter(ProductMedia.company_code == current_user.company_code) + + media = query.first() + if media is None: + logger.error(f"媒体不存在 - media_id: {media_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found") + update_data = media_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(media, field, value) + db.commit() + db.refresh(media) + logger.info(f"更新商品媒体成功 - media_id: {media_id}") + return media + + +@router.delete("/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_product_media( + media_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除商品媒体""" + logger.info(f"删除商品媒体 - media_id: {media_id}, user: {current_user.username}") + from routers.auth import is_admin_user + + query = db.query(ProductMedia).filter(ProductMedia.id == media_id) + + if not is_admin_user(current_user): + query = query.filter(ProductMedia.company_code == current_user.company_code) + + media = query.first() + if media is None: + logger.error(f"媒体不存在 - media_id: {media_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found") + + db.delete(media) + db.commit() + logger.info(f"删除商品媒体成功 - media_id: {media_id}") + return None diff --git a/routers/product_packaging.py b/routers/product_packaging.py new file mode 100644 index 0000000000000000000000000000000000000000..5adb6d4065d701645c6b29695cdf2ee5eb4c4315 --- /dev/null +++ b/routers/product_packaging.py @@ -0,0 +1,112 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models.product_packaging import ProductPackaging +from models.user import User +from schemas.product import ( + ProductPackagingResponse, + ProductPackagingCreate, + ProductPackagingUpdate +) +from routers.auth import get_current_user + +# 导入日志模块 +from utils.logger import logger + +router = APIRouter() + + +@router.get("/{product_number}/packaging", response_model=List[ProductPackagingResponse]) +async def read_product_packaging( + product_number: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取商品包装列表""" + logger.info(f"获取商品包装列表 - product_number: {product_number}, user: {current_user.username}") + from routers.auth import is_admin_user + + query = db.query(ProductPackaging).filter(ProductPackaging.product_number == product_number) + + if not is_admin_user(current_user): + query = query.filter(ProductPackaging.company_code == current_user.company_code) + + packaging = query.all() + logger.info(f"获取商品包装列表成功 - product_number: {product_number}, 共 {len(packaging)} 个包装") + return packaging + + +@router.post("/{product_number}/packaging", response_model=ProductPackagingResponse, status_code=status.HTTP_201_CREATED) +async def create_product_packaging( + product_number: str, + packaging: ProductPackagingCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建商品包装""" + logger.info(f"创建商品包装 - product_number: {product_number}, user: {current_user.username}") + packaging_data = packaging.model_dump() + packaging_data['product_number'] = product_number + packaging_data['company_code'] = current_user.company_code + db_packaging = ProductPackaging(**packaging_data) + db.add(db_packaging) + db.commit() + db.refresh(db_packaging) + logger.info(f"创建商品包装成功 - packaging_id: {db_packaging.id}, product_number: {product_number}") + return db_packaging + + +@router.put("/packaging/{packaging_id}", response_model=ProductPackagingResponse) +async def update_product_packaging( + packaging_id: int, + packaging_update: ProductPackagingUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新商品包装""" + logger.info(f"更新商品包装 - packaging_id: {packaging_id}, user: {current_user.username}") + from routers.auth import is_admin_user + + query = db.query(ProductPackaging).filter(ProductPackaging.id == packaging_id) + + if not is_admin_user(current_user): + query = query.filter(ProductPackaging.company_code == current_user.company_code) + + packaging = query.first() + if packaging is None: + logger.error(f"包装不存在 - packaging_id: {packaging_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Packaging not found") + update_data = packaging_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(packaging, field, value) + db.commit() + db.refresh(packaging) + logger.info(f"更新商品包装成功 - packaging_id: {packaging_id}") + return packaging + + +@router.delete("/packaging/{packaging_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_product_packaging( + packaging_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除商品包装""" + logger.info(f"删除商品包装 - packaging_id: {packaging_id}, user: {current_user.username}") + from routers.auth import is_admin_user + + query = db.query(ProductPackaging).filter(ProductPackaging.id == packaging_id) + + if not is_admin_user(current_user): + query = query.filter(ProductPackaging.company_code == current_user.company_code) + + packaging = query.first() + if packaging is None: + logger.error(f"包装不存在 - packaging_id: {packaging_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Packaging not found") + db.delete(packaging) + db.commit() + logger.info(f"删除商品包装成功 - packaging_id: {packaging_id}") + return None diff --git a/routers/product_utils.py b/routers/product_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..7216035581e4cd4e4671b72549d0c4164376b8c2 --- /dev/null +++ b/routers/product_utils.py @@ -0,0 +1,150 @@ +from fastapi import HTTPException, status +from sqlalchemy.orm import Session +from models.product import Product +from models.user import User +from utils.r2_uploader import get_r2_uploader, R2Config, get_cached_config, set_cached_config +from models.company_r2_config import CompanyR2Config +from typing import Optional +from dotenv import load_dotenv +from pathlib import Path +import os + +# 加载.env文件 +BASE_DIR = Path(__file__).parent.parent +load_dotenv(BASE_DIR / ".env") + + +def get_r2_config_from_env() -> Optional[R2Config]: + """从.env文件获取R2配置""" + r2_account_id = os.getenv('R2_ACCOUNT_ID', '') + r2_access_key_id = os.getenv('R2_ACCESS_KEY_ID', '') + r2_secret_access_key = os.getenv('R2_SECRET_ACCESS_KEY', '') + r2_bucket_name = os.getenv('R2_BUCKET_NAME', 'yomaton') + r2_public_url = os.getenv('R2_PUBLIC_URL', '') + r2_enabled = os.getenv('R2_ENABLED', 'true').lower() == 'true' + + if r2_enabled and r2_account_id and r2_access_key_id and r2_secret_access_key: + return R2Config({ + 'r2_account_id': r2_account_id, + 'r2_access_key_id': r2_access_key_id, + 'r2_secret_access_key': r2_secret_access_key, + 'r2_bucket_name': r2_bucket_name, + 'r2_public_url': r2_public_url, + 'r2_enabled': 1 if r2_enabled else 0 + }) + + return None + + +def validate_product_exists( + product_number: str, + current_user: User, + db: Session +) -> Product: + """ + 验证产品是否存在并返回产品对象 + + Args: + product_number: 产品货号 + current_user: 当前用户 + db: 数据库会话 + + Returns: + 产品对象 + + Raises: + HTTPException: 当产品不存在时抛出404错误 + """ + from routers.auth import is_admin_user + + query = db.query(Product).filter(Product.product_number == product_number) + + if not is_admin_user(current_user): + query = query.filter(Product.company_code == current_user.company_code) + + product = query.first() + + if product is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + return product + + +def get_company_r2_config(company_code: str, db: Session) -> Optional[R2Config]: + """ + 获取公司的 R2 配置 + + 优先从缓存获取,没有则从数据库获取,最后从.env获取并缓存 + + Args: + company_code: 公司代码 + db: 数据库会话 + + Returns: + R2配置对象,如果不存在则返回None + """ + cached_config = get_cached_config(company_code) + if cached_config: + return cached_config + + # 先从当前公司的数据库配置获取 + db_config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == company_code + ).first() + + # 如果没有,尝试从默认公司(0000)获取 + if not db_config: + db_config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == "0000" + ).first() + + if db_config and db_config.r2_enabled: + r2_config = R2Config({ + 'r2_account_id': db_config.r2_account_id, + 'r2_access_key_id': db_config.r2_access_key_id, + 'r2_secret_access_key': db_config.r2_secret_access_key, + 'r2_bucket_name': db_config.r2_bucket_name, + 'r2_public_url': db_config.r2_public_url, + 'r2_enabled': db_config.r2_enabled + }) + set_cached_config(company_code, r2_config) + return r2_config + + # 最后从.env获取 + env_config = get_r2_config_from_env() + if env_config: + set_cached_config(company_code, env_config) + return env_config + + return None + + +def delete_r2_and_local_files( + r2_key: Optional[str], + thumbnail_r2_key: Optional[str], + file_path: Optional[str], + company_code: str, + db: Session +) -> None: + """ + 删除R2文件(不再删除本地文件) + + Args: + r2_key: R2文件键 + thumbnail_r2_key: R2缩略图文件键 + file_path: 本地文件路径(已不再使用) + company_code: 公司代码 + db: 数据库会话 + """ + r2_config = get_company_r2_config(company_code, db) + + if r2_config: + r2_uploader = get_r2_uploader() + if r2_uploader.is_available(r2_config): + if r2_key: + r2_uploader.delete_file(r2_key, r2_config) + if thumbnail_r2_key: + r2_uploader.delete_file(thumbnail_r2_key, r2_config) diff --git a/routers/products.py b/routers/products.py new file mode 100644 index 0000000000000000000000000000000000000000..9f42208957e169b3aafd2b1d509292bb0e536124 --- /dev/null +++ b/routers/products.py @@ -0,0 +1,188 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional +import uuid + +from database import get_db +from models.product import Product +from models.user import User +from schemas.product import ( + ProductResponse, ProductCreate, ProductUpdate, ProductDetailResponse +) +from routers.auth import get_current_user, is_admin_user + +# 导入日志模块 +from utils.logger import logger + +router = APIRouter() + + +def generate_product_number(): + """生成 11 位 UUID 货号""" + return uuid.uuid4().hex[:11].upper() + + +@router.get("/", response_model=List[ProductResponse]) +async def read_products( + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None, + status: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取产品列表""" + logger.info(f"获取产品列表 - skip: {skip}, limit: {limit}, category_id: {category_id}, status: {status}, user: {current_user.username}") + query = db.query(Product) + if not is_admin_user(current_user): + query = query.filter(Product.company_code == current_user.company_code) + if category_id is not None: + query = query.filter(Product.category_id == category_id) + if status is not None: + query = query.filter(Product.status == status) + products = query.order_by(Product.sort_order, Product.id).offset(skip).limit(limit).all() + logger.info(f"获取产品列表成功 - 共 {len(products)} 个产品") + return products + + +@router.get("/{product_number}", response_model=ProductDetailResponse) +async def read_product_detail( + product_number: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取单个产品详情(包含所有关联数据)""" + logger.info(f"获取产品详情 - product_number: {product_number}, user: {current_user.username}") + from models.product_certification import ProductCertification + from models.product_media import ProductMedia + from models.product_packaging import ProductPackaging + + query = db.query(Product).filter(Product.product_number == product_number) + if not is_admin_user(current_user): + query = query.filter(Product.company_code == current_user.company_code) + + product = query.first() + if product is None: + logger.error(f"产品不存在 - product_number: {product_number}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + certifications = db.query(ProductCertification).filter( + ProductCertification.product_number == product_number + ).all() + + media = db.query(ProductMedia).filter( + ProductMedia.product_number == product_number + ).order_by(ProductMedia.sort_order).all() + + packaging = db.query(ProductPackaging).filter( + ProductPackaging.product_number == product_number + ).all() + + logger.info(f"获取产品详情成功 - product_number: {product_number}, 认证: {len(certifications)}, 媒体: {len(media)}, 包装: {len(packaging)}") + return ProductDetailResponse( + **product.__dict__, + certifications=certifications, + media=media, + packaging=packaging + ) + + +@router.post("/", response_model=ProductResponse, status_code=status.HTTP_201_CREATED) +async def create_product( + product: ProductCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建新产品""" + logger.info(f"创建新产品 - user: {current_user.username}") + product_data = product.model_dump() + if not is_admin_user(current_user): + product_data['company_code'] = current_user.company_code + + product_data['product_number'] = generate_product_number() + + db_product = Product(**product_data) + db.add(db_product) + db.commit() + db.refresh(db_product) + logger.info(f"创建新产品成功 - product_number: {db_product.product_number}") + return db_product + + +@router.put("/{product_number}", response_model=ProductResponse) +async def update_product( + product_number: str, + product_update: ProductUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新产品信息""" + logger.info(f"更新产品信息 - product_number: {product_number}, user: {current_user.username}") + query = db.query(Product).filter(Product.product_number == product_number) + if not is_admin_user(current_user): + query = query.filter(Product.company_code == current_user.company_code) + + product = query.first() + if product is None: + logger.error(f"产品不存在 - product_number: {product_number}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + update_data = product_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(product, field, value) + db.commit() + db.refresh(product) + logger.info(f"更新产品信息成功 - product_number: {product_number}") + return product + + +@router.delete("/{product_number}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_product( + product_number: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除产品(级联删除所有关联数据)""" + logger.info(f"删除产品 - product_number: {product_number}, user: {current_user.username}") + from models.product_certification import ProductCertification + from models.product_media import ProductMedia + from models.product_packaging import ProductPackaging + + query = db.query(Product).filter(Product.product_number == product_number) + if not is_admin_user(current_user): + query = query.filter(Product.company_code == current_user.company_code) + + product = query.first() + if product is None: + logger.error(f"产品不存在 - product_number: {product_number}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + if product.status == 1: + logger.error(f"产品未下架,无法删除 - product_number: {product_number}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="请先下架商品才能删除" + ) + + # 统计关联数据 + certifications_count = db.query(ProductCertification).filter(ProductCertification.product_number == product_number).count() + media_count = db.query(ProductMedia).filter(ProductMedia.product_number == product_number).count() + packaging_count = db.query(ProductPackaging).filter(ProductPackaging.product_number == product_number).count() + + # 级联删除 + db.query(ProductCertification).filter(ProductCertification.product_number == product_number).delete() + db.query(ProductMedia).filter(ProductMedia.product_number == product_number).delete() + db.query(ProductPackaging).filter(ProductPackaging.product_number == product_number).delete() + + db.delete(product) + db.commit() + logger.info(f"删除产品成功 - product_number: {product_number}, 认证: {certifications_count}, 媒体: {media_count}, 包装: {packaging_count}") + return None diff --git a/routers/r2_config.py b/routers/r2_config.py new file mode 100644 index 0000000000000000000000000000000000000000..ca4601cae3986c8c15b3cae3b1e41df4323783d3 --- /dev/null +++ b/routers/r2_config.py @@ -0,0 +1,378 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional + +from database import get_db +from models.company_r2_config import CompanyR2Config +from models.company import Company +from models.user import User +from schemas.product import ( + CompanyR2ConfigResponse, CompanyR2ConfigCreate, CompanyR2ConfigUpdate +) +from routers.auth import get_current_user, is_admin_user +from utils.r2_uploader import set_cached_config, clear_cached_config, R2Config + +router = APIRouter() + + +def verify_superadmin(current_user: User): + """验证用户是否为超级管理员或admin用户""" + if not current_user.is_superadmin and not is_admin_user(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only superadmin can access this endpoint" + ) + + +@router.get("/all", response_model=List[CompanyR2ConfigResponse]) +async def get_all_r2_configs( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取所有公司的 R2 配置(仅超管)""" + verify_superadmin(current_user) + configs = db.query(CompanyR2Config).all() + + result = [] + for config in configs: + company = db.query(Company).filter( + Company.company_code == config.company_code + ).first() + config_dict = { + 'id': config.id, + 'company_code': config.company_code, + 'company_name': company.name if company else None, + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled, + 'created_at': config.created_at, + 'updated_at': config.updated_at + } + result.append(config_dict) + + return result + + +@router.get("/config/{company_code}", response_model=Optional[CompanyR2ConfigResponse]) +async def get_r2_config_by_company( + company_code: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取指定公司的 R2 配置(仅超管)""" + verify_superadmin(current_user) + config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == company_code + ).first() + + if config: + r2_config = R2Config({ + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled + }) + set_cached_config(company_code, r2_config) + + company = db.query(Company).filter( + Company.company_code == config.company_code + ).first() + config_dict = { + 'id': config.id, + 'company_code': config.company_code, + 'company_name': company.name if company else None, + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled, + 'created_at': config.created_at, + 'updated_at': config.updated_at + } + return config_dict + + return config + + +@router.post("/config/{company_code}", response_model=CompanyR2ConfigResponse, status_code=status.HTTP_201_CREATED) +async def create_r2_config_for_company( + company_code: str, + config_data: CompanyR2ConfigCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """为指定公司创建或更新 R2 配置(仅超管)""" + verify_superadmin(current_user) + + existing_config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == company_code + ).first() + + if existing_config: + update_data = config_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(existing_config, field, value) + db.commit() + db.refresh(existing_config) + config = existing_config + else: + config_dict = config_data.model_dump() + config_dict['company_code'] = company_code + config = CompanyR2Config(**config_dict) + db.add(config) + db.commit() + db.refresh(config) + + clear_cached_config(company_code) + r2_config = R2Config({ + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled + }) + set_cached_config(company_code, r2_config) + + company = db.query(Company).filter( + Company.company_code == config.company_code + ).first() + config_dict = { + 'id': config.id, + 'company_code': config.company_code, + 'company_name': company.name if company else None, + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled, + 'created_at': config.created_at, + 'updated_at': config.updated_at + } + return config_dict + + +@router.put("/config/{company_code}", response_model=CompanyR2ConfigResponse) +async def update_r2_config_for_company( + company_code: str, + config_update: CompanyR2ConfigUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新指定公司的 R2 配置(仅超管)""" + verify_superadmin(current_user) + + config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == company_code + ).first() + + if config is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="R2 config not found" + ) + + update_data = config_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(config, field, value) + + db.commit() + db.refresh(config) + + clear_cached_config(company_code) + r2_config = R2Config({ + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled + }) + set_cached_config(company_code, r2_config) + + company = db.query(Company).filter( + Company.company_code == config.company_code + ).first() + config_dict = { + 'id': config.id, + 'company_code': config.company_code, + 'company_name': company.name if company else None, + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled, + 'created_at': config.created_at, + 'updated_at': config.updated_at + } + return config_dict + + +@router.delete("/config/{company_code}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_r2_config_for_company( + company_code: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除指定公司的 R2 配置(仅超管)""" + verify_superadmin(current_user) + + config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == company_code + ).first() + + if config is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="R2 config not found" + ) + + db.delete(config) + db.commit() + + clear_cached_config(company_code) + + return None + + +@router.get("/config", response_model=Optional[CompanyR2ConfigResponse]) +async def get_company_r2_config( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取当前公司的 R2 配置""" + config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == current_user.company_code + ).first() + + if config: + r2_config = R2Config({ + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled + }) + set_cached_config(current_user.company_code, r2_config) + + return config + + +@router.post("/config", response_model=CompanyR2ConfigResponse, status_code=status.HTTP_201_CREATED) +async def create_company_r2_config( + config_data: CompanyR2ConfigCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建或更新当前公司的 R2 配置""" + existing_config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == current_user.company_code + ).first() + + if existing_config: + update_data = config_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(existing_config, field, value) + db.commit() + db.refresh(existing_config) + config = existing_config + else: + config_dict = config_data.model_dump() + config_dict['company_code'] = current_user.company_code + config = CompanyR2Config(**config_dict) + db.add(config) + db.commit() + db.refresh(config) + + clear_cached_config(current_user.company_code) + r2_config = R2Config({ + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled + }) + set_cached_config(current_user.company_code, r2_config) + + return config + + +@router.put("/config", response_model=CompanyR2ConfigResponse) +async def update_company_r2_config( + config_update: CompanyR2ConfigUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新当前公司的 R2 配置""" + config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == current_user.company_code + ).first() + + if config is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="R2 config not found" + ) + + update_data = config_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(config, field, value) + + db.commit() + db.refresh(config) + + clear_cached_config(current_user.company_code) + r2_config = R2Config({ + 'r2_account_id': config.r2_account_id, + 'r2_access_key_id': config.r2_access_key_id, + 'r2_secret_access_key': config.r2_secret_access_key, + 'r2_bucket_name': config.r2_bucket_name, + 'r2_public_url': config.r2_public_url, + 'r2_enabled': config.r2_enabled + }) + set_cached_config(current_user.company_code, r2_config) + + return config + + +@router.delete("/config", status_code=status.HTTP_204_NO_CONTENT) +async def delete_company_r2_config( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除当前公司的 R2 配置""" + config = db.query(CompanyR2Config).filter( + CompanyR2Config.company_code == current_user.company_code + ).first() + + if config is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="R2 config not found" + ) + + db.delete(config) + db.commit() + + clear_cached_config(current_user.company_code) + + return None + + +@router.post("/config/clear-cache", status_code=status.HTTP_200_OK) +async def clear_r2_config_cache( + current_user: User = Depends(get_current_user) +): + """清除当前公司的 R2 配置缓存""" + clear_cached_config(current_user.company_code) + return {"message": "Cache cleared successfully"} diff --git a/routers/statistics.py b/routers/statistics.py new file mode 100644 index 0000000000000000000000000000000000000000..e5477563e06b398aa344d031281cf134c6fa0b66 --- /dev/null +++ b/routers/statistics.py @@ -0,0 +1,349 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import datetime, timedelta +from typing import Dict, List, Optional + +from database import get_db +from models.user import User +from models.membership import Membership +from models.member_level import MemberLevel +from models.product import Product +from models.product_media import ProductMedia +from models.media import Media +from models.site_visit import SiteVisit +from routers.auth import get_current_user +from utils.ip_location import ip_location_service + +router = APIRouter() + + +def get_client_ip(request: Request) -> str: + """获取客户端真实IP地址""" + # 优先从 X-Forwarded-For 获取(经过代理的情况) + x_forwarded_for = request.headers.get("x-forwarded-for") + if x_forwarded_for: + # X-Forwarded-For 可能包含多个IP,取第一个 + return x_forwarded_for.split(",")[0].strip() + + # 从 X-Real-IP 获取 + x_real_ip = request.headers.get("x-real-ip") + if x_real_ip: + return x_real_ip + + # 从请求的 client 获取 + if request.client: + return request.client.host + + return "0.0.0.0" + + +@router.post("/visits/record") +async def record_site_visit( + request: Request, + db: Session = Depends(get_db) +): + """记录独立站访问(公开接口,无需认证)""" + try: + body = await request.json() + except: + body = {} + + # 获取客户端IP + ip_address = get_client_ip(request) + + # 解析IP地理位置 + location = ip_location_service.get_location(ip_address) + + # 获取 company_code(从请求参数或域名) + company_code = body.get("company_code", "default") + + # 创建访问记录 + visit = SiteVisit( + company_code=company_code, + ip_address=ip_address, + visitor_id=body.get("visitor_id"), + country=location.get("country"), + region=location.get("region"), + city=location.get("city"), + latitude=location.get("latitude"), + longitude=location.get("longitude"), + user_agent=request.headers.get("user-agent"), + referer=request.headers.get("referer"), + page_url=body.get("page_url", request.url.path) + ) + + db.add(visit) + db.commit() + db.refresh(visit) + + return { + "status": "success", + "visit_id": visit.id, + "location": location + } + + +@router.get("/dashboard") +async def get_dashboard_statistics( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取仪表盘统计指标""" + company_code = current_user.company_code + + # 1. SKU数量 + sku_count = db.query(Product).filter( + Product.company_code == company_code + ).count() + + # 2. 图片占用空间 (Media模型的file_size是字节) + total_image_size_bytes = db.query(func.sum(Media.file_size)).filter( + Media.company_code == company_code, + Media.media_type == 'image' + ).scalar() or 0 + + # 转换为MB (字节 -> MB) + total_image_size_mb = total_image_size_bytes / (1024 * 1024) + # 转换为GB + total_image_size_gb = round(total_image_size_mb / 1024, 2) + + # 3. 会员信息 + membership = db.query(Membership).filter( + Membership.company_code == company_code + ).order_by(Membership.end_date.desc()).first() + + days_remaining = 0 + member_level = "" + member_level_name = "" + + if membership: + if membership.end_date: + delta = membership.end_date - datetime.now().date() + days_remaining = max(0, delta.days) + + member_level = membership.level + + level_info = db.query(MemberLevel).filter( + MemberLevel.level == membership.level + ).first() + + if level_info: + member_level_name = level_info.name + + # 4. 昨日独立站IP访问数 + yesterday = datetime.now().date() - timedelta(days=1) + yesterday_start = datetime.combine(yesterday, datetime.min.time()) + yesterday_end = datetime.combine(yesterday, datetime.max.time()) + + yesterday_ip_count = db.query( + SiteVisit.ip_address + ).filter( + SiteVisit.company_code == company_code, + SiteVisit.visit_date >= yesterday_start, + SiteVisit.visit_date <= yesterday_end + ).distinct().count() + + # 5. 近7天独立站IP访问数 + seven_days_ago = datetime.now().date() - timedelta(days=7) + seven_days_start = datetime.combine(seven_days_ago, datetime.min.time()) + + seven_days_ip_count = db.query( + SiteVisit.ip_address + ).filter( + SiteVisit.company_code == company_code, + SiteVisit.visit_date >= seven_days_start + ).distinct().count() + + # 6. 近30天独立站访问数 + thirty_days_ago = datetime.now().date() - timedelta(days=30) + thirty_days_start = datetime.combine(thirty_days_ago, datetime.min.time()) + + thirty_days_visit_count = db.query(SiteVisit).filter( + SiteVisit.company_code == company_code, + SiteVisit.visit_date >= thirty_days_start + ).count() + + return { + "sku_count": sku_count, + "image_storage": { + "total_mb": round(total_image_size_mb, 2), + "total_gb": total_image_size_gb + }, + "membership": { + "level": member_level, + "level_name": member_level_name, + "days_remaining": days_remaining, + "end_date": membership.end_date if membership else None + }, + "site_visits": { + "yesterday_ips": yesterday_ip_count, + "last_7_days_ips": seven_days_ip_count, + "last_30_days_visits": thirty_days_visit_count + } + } + + +@router.get("/visits/by-region") +async def get_visits_by_region( + current_user: User = Depends(get_current_user), + days: int = 30, + db: Session = Depends(get_db) +): + """获取按区域统计的IP访问数""" + company_code = current_user.company_code + + start_date = datetime.now().date() - timedelta(days=days) + start_datetime = datetime.combine(start_date, datetime.min.time()) + + # 按国家统计 + country_stats = db.query( + SiteVisit.country, + SiteVisit.ip_address + ).filter( + SiteVisit.company_code == company_code, + SiteVisit.visit_date >= start_datetime, + SiteVisit.country.isnot(None) + ).group_by( + SiteVisit.country, + SiteVisit.ip_address + ).all() + + # 统计数据 + country_ip_counts: Dict[str, int] = {} + for country, ip in country_stats: + if country: + country_ip_counts[country] = country_ip_counts.get(country, 0) + 1 + + # 按地区/大洲分类 + continent_mapping = { + "中国": "亚洲", + "日本": "亚洲", + "韩国": "亚洲", + "印度": "亚洲", + "泰国": "亚洲", + "越南": "亚洲", + "马来西亚": "亚洲", + "新加坡": "亚洲", + "菲律宾": "亚洲", + "印度尼西亚": "亚洲", + "美国": "北美洲", + "加拿大": "北美洲", + "墨西哥": "北美洲", + "英国": "欧洲", + "德国": "欧洲", + "法国": "欧洲", + "意大利": "欧洲", + "西班牙": "欧洲", + "荷兰": "欧洲", + "波兰": "欧洲", + "俄罗斯": "欧洲", + "巴西": "南美洲", + "阿根廷": "南美洲", + "智利": "南美洲", + "澳大利亚": "大洋洲", + "新西兰": "大洋洲", + "南非": "非洲", + "埃及": "非洲", + "尼日利亚": "非洲" + } + + continent_stats: Dict[str, int] = {} + for country, count in country_ip_counts.items(): + continent = continent_mapping.get(country, "其他") + continent_stats[continent] = continent_stats.get(continent, 0) + count + + # 构建世界地图数据 + world_map_data = [] + for country, count in country_ip_counts.items(): + world_map_data.append({ + "country": country, + "value": count + }) + + # 按访问量排序 + world_map_data.sort(key=lambda x: x["value"], reverse=True) + + return { + "period_days": days, + "continent_stats": continent_stats, + "country_stats": country_ip_counts, + "world_map_data": world_map_data, + "total_unique_ips": sum(country_ip_counts.values()) + } + + +@router.get("/visits/by-country/{country}") +async def get_visits_by_country( + country: str, + current_user: User = Depends(get_current_user), + days: int = 30, + db: Session = Depends(get_db) +): + """获取指定国家的访问详情""" + company_code = current_user.company_code + + start_date = datetime.now().date() - timedelta(days=days) + start_datetime = datetime.combine(start_date, datetime.min.time()) + + visits = db.query(SiteVisit).filter( + SiteVisit.company_code == company_code, + SiteVisit.country == country, + SiteVisit.visit_date >= start_datetime + ).order_by( + SiteVisit.visit_date.desc() + ).limit(100).all() + + return [ + { + "id": visit.id, + "ip_address": visit.ip_address, + "visit_date": visit.visit_date, + "page_url": visit.page_url, + "city": visit.city, + "region": visit.region + } + for visit in visits + ] + + +@router.get("/visits/daily-trend") +async def get_visits_daily_trend( + current_user: User = Depends(get_current_user), + days: int = 30, + db: Session = Depends(get_db) +): + """获取每日访问趋势""" + company_code = current_user.company_code + + start_date = datetime.now().date() - timedelta(days=days) + start_datetime = datetime.combine(start_date, datetime.min.time()) + + daily_visits = db.query( + SiteVisit.visit_date, + SiteVisit.ip_address + ).filter( + SiteVisit.company_code == company_code, + SiteVisit.visit_date >= start_datetime + ).all() + + # 按日期统计独立IP数 + daily_ip_counts: Dict[str, set] = {} + for visit_date, ip in daily_visits: + date_str = visit_date.strftime("%Y-%m-%d") + if date_str not in daily_ip_counts: + daily_ip_counts[date_str] = set() + daily_ip_counts[date_str].add(ip) + + # 构建趋势数据 + trend_data = [] + for date_str in sorted(daily_ip_counts.keys()): + trend_data.append({ + "date": date_str, + "unique_ips": len(daily_ip_counts[date_str]) + }) + + return { + "period_days": days, + "trend_data": trend_data + } diff --git a/routers/template.py b/routers/template.py new file mode 100644 index 0000000000000000000000000000000000000000..6af9b96b5671676643a4f52cbe34908551c60261 --- /dev/null +++ b/routers/template.py @@ -0,0 +1,361 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import List, Optional +import json + +from database import get_db +from models.template import Template +from models.company import Company +from models.user import User +from schemas.template import TemplateResponse, TemplateCreate, TemplateUpdate, TemplateStatusUpdate +from routers.auth import get_current_user, get_current_superadmin +from routers.operation_log import create_operation_log + +router = APIRouter() + +# 获取模板列表 +@router.get("/", response_model=List[TemplateResponse]) +async def get_templates( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # 普通用户只能看到可用的模板,超级管理员可以看到所有模板 + if current_user.is_superadmin: + templates = db.query(Template).all() + else: + templates = db.query(Template).filter(Template.status == 1).all() + + # 转换features和regions字段从JSON字符串到列表 + result = [] + for template in templates: + template_dict = { + 'id': template.id, + 'key': template.key, + 'name': template.name, + 'description': template.description, + 'color': template.color, + 'features': [], + 'applicable_scene': template.applicable_scene, + 'regions': [], + 'select_count': template.select_count or 0, + 'use_count': template.use_count or 0, + 'status': template.status or 0, + 'created_at': template.created_at, + 'updated_at': template.updated_at + } + + if template.features: + try: + template_dict['features'] = json.loads(template.features) + except: + template_dict['features'] = [] + + if template.regions: + try: + template_dict['regions'] = json.loads(template.regions) + except: + template_dict['regions'] = [] + + result.append(template_dict) + + return result + +# 通过模板key获取模板信息 +@router.get("/{template_key}", response_model=TemplateResponse) +async def get_template_by_key( + template_key: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + template = db.query(Template).filter(Template.key == template_key).first() + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Template with key '{template_key}' not found" + ) + + # 构造响应数据 + template_dict = { + 'id': template.id, + 'key': template.key, + 'name': template.name, + 'description': template.description, + 'color': template.color, + 'features': [], + 'applicable_scene': template.applicable_scene, + 'regions': [], + 'select_count': template.select_count or 0, + 'use_count': template.use_count or 0, + 'status': template.status or 1, + 'created_at': template.created_at, + 'updated_at': template.updated_at + } + + # 转换features和regions字段从JSON字符串到列表 + if template.features: + try: + template_dict['features'] = json.loads(template.features) + except: + template_dict['features'] = [] + + if template.regions: + try: + template_dict['regions'] = json.loads(template.regions) + except: + template_dict['regions'] = [] + + return template_dict + +# 创建模板(仅超级管理员可操作) +@router.post("/", response_model=TemplateResponse) +async def create_template( + template: TemplateCreate, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + # 检查模板key是否已存在 + existing_template = db.query(Template).filter(Template.key == template.key).first() + if existing_template: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Template with key '{template.key}' already exists" + ) + + # 转换features和regions列表为JSON字符串 + template_data = template.model_dump() + if template_data.get('features'): + template_data['features'] = json.dumps(template_data['features'], ensure_ascii=False) + if template_data.get('regions'): + template_data['regions'] = json.dumps(template_data['regions'], ensure_ascii=False) + + db_template = Template(**template_data) + db.add(db_template) + db.commit() + db.refresh(db_template) + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="template", + operation_type="add", + operation_detail=f"新增模板:{db_template.name}(key:{db_template.key})", + company_code=current_user.company_code + ) + + # 转换features和regions字段从JSON字符串到列表 + if db_template.features: + try: + db_template.features = json.loads(db_template.features) + except: + db_template.features = [] + if db_template.regions: + try: + db_template.regions = json.loads(db_template.regions) + except: + db_template.regions = [] + + return db_template + +# 更新模板(仅超级管理员可操作) +@router.put("/{template_key}", response_model=TemplateResponse) +async def update_template( + template_key: str, + template_update: TemplateUpdate, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + template = db.query(Template).filter(Template.key == template_key).first() + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Template with key '{template_key}' not found" + ) + + # 记录修改前的数据 + before_data_dict = { + "name": template.name, + "description": template.description, + "color": template.color, + "features": template.features, + "applicable_scene": template.applicable_scene, + "regions": template.regions + } + + # 更新模板信息 + update_data = template_update.model_dump(exclude_unset=True) + if 'features' in update_data: + update_data['features'] = json.dumps(update_data['features'], ensure_ascii=False) + if 'regions' in update_data: + update_data['regions'] = json.dumps(update_data['regions'], ensure_ascii=False) + + for field, value in update_data.items(): + setattr(template, field, value) + + db.commit() + db.refresh(template) + + # 记录修改后的数据 + after_data_dict = { + "name": template.name, + "description": template.description, + "color": template.color, + "features": template.features, + "applicable_scene": template.applicable_scene, + "regions": template.regions + } + + # 只记录有修改的字段,使用中文解析 + modified_fields = [] + field_names = { + "name": "模板名称", + "description": "模板描述", + "color": "模板主色调", + "features": "模板特性", + "applicable_scene": "适用场景", + "regions": "适用区域" + } + + for field, chinese_name in field_names.items(): + if before_data_dict[field] != after_data_dict[field]: + modified_fields.append(f"{chinese_name}:{before_data_dict[field]} → {after_data_dict[field]}") + + # 生成中文描述的修改内容 + if modified_fields: + modification_detail = ";".join(modified_fields) + else: + modification_detail = "无字段修改" + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="template", + operation_type="edit", + operation_detail=f"编辑模板:{template.name}(key:{template.key})", + before_data=modification_detail, + after_data=None, + company_code=current_user.company_code + ) + + # 转换features和regions字段从JSON字符串到列表 + if template.features: + try: + template.features = json.loads(template.features) + except: + template.features = [] + if template.regions: + try: + template.regions = json.loads(template.regions) + except: + template.regions = [] + + return template + +# 删除模板(仅超级管理员可操作) +@router.delete("/{template_key}") +async def delete_template( + template_key: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + template = db.query(Template).filter(Template.key == template_key).first() + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Template with key '{template_key}' not found" + ) + + # 记录模板名称和key,用于日志 + template_name = template.name + template_key = template.key + + db.delete(template) + db.commit() + + # 记录操作日志 + create_operation_log( + db=db, + user=current_user, + module="template", + operation_type="delete", + operation_detail=f"删除模板:{template_name}(key:{template_key})", + company_code=current_user.company_code + ) + + return {"message": f"Template with key '{template_key}' deleted successfully"} + + +# 设置模板状态(仅超级管理员可操作) +@router.put("/{template_key}/status", response_model=TemplateResponse) +async def update_template_status( + template_key: str, + status_update: TemplateStatusUpdate, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + template = db.query(Template).filter(Template.key == template_key).first() + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Template with key '{template_key}' not found" + ) + + # 验证状态值 + if status_update.status not in [0, 1]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Status must be 0 (unavailable) or 1 (available)" + ) + + # 记录修改前的状态 + old_status = template.status + + # 更新状态 + template.status = status_update.status + db.commit() + db.refresh(template) + + # 记录操作日志 + status_text = {0: "不可用", 1: "可用"} + create_operation_log( + db=db, + user=current_user, + module="template", + operation_type="edit", + operation_detail=f"设置模板状态:{template.name}(key:{template.key}),状态:{status_text.get(old_status, '未知')} → {status_text.get(status_update.status, '未知')}", + company_code=current_user.company_code + ) + + # 构造响应数据 + template_dict = { + 'id': template.id, + 'key': template.key, + 'name': template.name, + 'description': template.description, + 'color': template.color, + 'features': [], + 'applicable_scene': template.applicable_scene, + 'regions': [], + 'select_count': template.select_count or 0, + 'use_count': template.use_count or 0, + 'status': template.status or 1, + 'created_at': template.created_at, + 'updated_at': template.updated_at + } + + # 转换features和regions字段从JSON字符串到列表 + if template.features: + try: + template_dict['features'] = json.loads(template.features) + except: + template_dict['features'] = [] + + if template.regions: + try: + template_dict['regions'] = json.loads(template.regions) + except: + template_dict['regions'] = [] + + return template_dict \ No newline at end of file diff --git a/schemas/__pycache__/category.cpython-314.pyc b/schemas/__pycache__/category.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e430d4c45aca01e6081ce409716fd3bdd7759e36 Binary files /dev/null and b/schemas/__pycache__/category.cpython-314.pyc differ diff --git a/schemas/__pycache__/company.cpython-314.pyc b/schemas/__pycache__/company.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9281090a180f25fac30c39b546544a3dc8156e8a Binary files /dev/null and b/schemas/__pycache__/company.cpython-314.pyc differ diff --git a/schemas/__pycache__/contact.cpython-314.pyc b/schemas/__pycache__/contact.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d98844a722b731658438e2d251c9d69876986bad Binary files /dev/null and b/schemas/__pycache__/contact.cpython-314.pyc differ diff --git a/schemas/__pycache__/customer_relationship.cpython-314.pyc b/schemas/__pycache__/customer_relationship.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c52eacf97c48261ecb72fa0bb26794ff51c8f26b Binary files /dev/null and b/schemas/__pycache__/customer_relationship.cpython-314.pyc differ diff --git a/schemas/__pycache__/media.cpython-314.pyc b/schemas/__pycache__/media.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8b62ad349df599869715cffe9ed5dd051c3edde Binary files /dev/null and b/schemas/__pycache__/media.cpython-314.pyc differ diff --git a/schemas/__pycache__/member_level.cpython-314.pyc b/schemas/__pycache__/member_level.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7498a5c2dbc5e7316a4dbabede11acf550450a70 Binary files /dev/null and b/schemas/__pycache__/member_level.cpython-314.pyc differ diff --git a/schemas/__pycache__/membership.cpython-314.pyc b/schemas/__pycache__/membership.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce121adb002aba365e65a0436760204ac672c89f Binary files /dev/null and b/schemas/__pycache__/membership.cpython-314.pyc differ diff --git a/schemas/__pycache__/operation_log.cpython-314.pyc b/schemas/__pycache__/operation_log.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..699108182e1f1dbf5be98e0eae9c66867c536612 Binary files /dev/null and b/schemas/__pycache__/operation_log.cpython-314.pyc differ diff --git a/schemas/__pycache__/product.cpython-314.pyc b/schemas/__pycache__/product.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74c818c62eb36d3d073ed1482c9db997cbd98074 Binary files /dev/null and b/schemas/__pycache__/product.cpython-314.pyc differ diff --git a/schemas/__pycache__/template.cpython-314.pyc b/schemas/__pycache__/template.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..63c746a6cdec35aa0c5bac43ec1b6ec478fa0531 Binary files /dev/null and b/schemas/__pycache__/template.cpython-314.pyc differ diff --git a/schemas/__pycache__/user.cpython-314.pyc b/schemas/__pycache__/user.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..83780923fc6ac9296dbd95095ed1b2e672b2f7b7 Binary files /dev/null and b/schemas/__pycache__/user.cpython-314.pyc differ diff --git a/schemas/category.py b/schemas/category.py new file mode 100644 index 0000000000000000000000000000000000000000..bede1223b5694568e6b2aa3bf62701cbfbe3cc41 --- /dev/null +++ b/schemas/category.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +class CategoryBase(BaseModel): + parent_code: str = Field(default="", description="父分类编码,空为顶级分类", max_length=100) + code: str = Field(default="", description="分类编码,级联编码格式", max_length=100) + name: str = Field(default="", description="分类名称", max_length=100) + sort_order: int = Field(default=0, description="排序顺序") + status: int = Field(default=1, description="状态:0-禁用,1-启用") + company_code: str = Field(default="", description="公司编码", max_length=50) + +class CategoryCreate(CategoryBase): + pass + +class CategoryUpdate(BaseModel): + parent_code: Optional[str] = None + code: Optional[str] = None + name: Optional[str] = None + sort_order: Optional[int] = None + status: Optional[int] = None + +class CategoryResponse(CategoryBase): + id: int + created_at: datetime + updated_at: datetime + product_count: Optional[int] = 0 + + class Config: + from_attributes = True + +class CategoryTreeResponse(CategoryResponse): + children: List['CategoryTreeResponse'] = [] + + class Config: + from_attributes = True + +CategoryTreeResponse.model_rebuild() diff --git a/schemas/company.py b/schemas/company.py new file mode 100644 index 0000000000000000000000000000000000000000..366dac1bcf193d70a8197a38b3f26d0cc8e2b808 --- /dev/null +++ b/schemas/company.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + +class CompanyBase(BaseModel): + name: str + logo_url: Optional[str] = None + description: Optional[str] = None + address: str + phone: str + email: str + website: Optional[str] = None + business_license: Optional[str] = None + established_date: Optional[datetime] = None + employees_count: Optional[int] = None + business_scope: Optional[str] = None + level: int = 0 + template: str = 'Home' + +class CompanyCreate(CompanyBase): + pass + +class CompanyUpdate(BaseModel): + name: Optional[str] = None + logo_url: Optional[str] = None + description: Optional[str] = None + address: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + website: Optional[str] = None + business_license: Optional[str] = None + established_date: Optional[datetime] = None + employees_count: Optional[int] = None + business_scope: Optional[str] = None + level: Optional[int] = None + template: Optional[str] = None + +class CompanyResponse(CompanyBase): + id: int + company_code: str + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True \ No newline at end of file diff --git a/schemas/contact.py b/schemas/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..6b52be42b0b0ce9cd146d1d7f9f9d0403c624ea4 --- /dev/null +++ b/schemas/contact.py @@ -0,0 +1,48 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional, List + +class ContactBase(BaseModel): + contact_name: str = Field(..., description="联系人姓名") + phone: str = Field(..., description="联系电话") + email: str = Field(..., description="电子邮箱") + position: Optional[str] = Field(None, description="职位") + department: Optional[str] = Field(None, description="部门") + facebook: Optional[str] = Field(None, description="Facebook账号") + facebook_api_key: Optional[str] = Field(None, description="Facebook API Key") + facebook_app_secret: Optional[str] = Field(None, description="Facebook App Secret") + tiktok: Optional[str] = Field(None, description="TikTok账号") + tiktok_api_key: Optional[str] = Field(None, description="TikTok API Key") + tiktok_app_secret: Optional[str] = Field(None, description="TikTok App Secret") + whatsapp: Optional[str] = Field(None, description="WhatsApp账号") + whatsapp_api_key: Optional[str] = Field(None, description="WhatsApp API Key") + whatsapp_app_secret: Optional[str] = Field(None, description="WhatsApp App Secret") + wechat: Optional[str] = Field(None, description="微信账号") + linkedin: Optional[str] = Field(None, description="LinkedIn账号") + note: Optional[str] = Field(None, description="备注") + +class ContactCreate(ContactBase): + pass + +class ContactUpdate(ContactBase): + contact_name: Optional[str] = Field(None, description="联系人姓名") + phone: Optional[str] = Field(None, description="联系电话") + email: Optional[str] = Field(None, description="电子邮箱") + facebook: Optional[str] = Field(None, description="Facebook账号") + facebook_api_key: Optional[str] = Field(None, description="Facebook API Key") + facebook_app_secret: Optional[str] = Field(None, description="Facebook App Secret") + tiktok: Optional[str] = Field(None, description="TikTok账号") + tiktok_api_key: Optional[str] = Field(None, description="TikTok API Key") + tiktok_app_secret: Optional[str] = Field(None, description="TikTok App Secret") + whatsapp: Optional[str] = Field(None, description="WhatsApp账号") + whatsapp_api_key: Optional[str] = Field(None, description="WhatsApp API Key") + whatsapp_app_secret: Optional[str] = Field(None, description="WhatsApp App Secret") + +class ContactResponse(ContactBase): + id: int + company_code: str + created_at: datetime + updated_at: Optional[datetime] + + class Config: + from_attributes = True diff --git a/schemas/customer_relationship.py b/schemas/customer_relationship.py new file mode 100644 index 0000000000000000000000000000000000000000..8dcad43475eed5e4bef9fb30425d3f254688f71b --- /dev/null +++ b/schemas/customer_relationship.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, Dict + +class CustomerRelationshipBase(BaseModel): + company_code: str + customer_name: str + contact_person: str + phone: Optional[str] = None + email: Optional[str] = None + social_media: Optional[Dict[str, str]] = None + address: Optional[str] = None + notes: Optional[str] = None + +class CustomerRelationshipCreate(CustomerRelationshipBase): + pass + +class CustomerRelationshipUpdate(BaseModel): + customer_name: Optional[str] = None + contact_person: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + social_media: Optional[Dict[str, str]] = None + address: Optional[str] = None + notes: Optional[str] = None + +class CustomerRelationshipResponse(CustomerRelationshipBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True \ No newline at end of file diff --git a/schemas/media.py b/schemas/media.py new file mode 100644 index 0000000000000000000000000000000000000000..fd0c8f7c5e7dff19efa5451dd91f3dd0260f642a --- /dev/null +++ b/schemas/media.py @@ -0,0 +1,169 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime +from enum import Enum + + +class MediaStatus(str, Enum): + PENDING = "pending" + UPLOADING = "uploading" + SUCCESS = "success" + FAILED = "failed" + + +class MediaTagBase(BaseModel): + tag_name: str + tag_color: Optional[str] = "#3b82f6" + sort_order: Optional[int] = 0 + + +class MediaTagCreate(MediaTagBase): + pass + + +class MediaTagUpdate(BaseModel): + tag_name: Optional[str] = None + tag_color: Optional[str] = None + sort_order: Optional[int] = None + + +class MediaTagResponse(MediaTagBase): + id: int + company_code: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class MediaTagMappingResponse(BaseModel): + id: int + media_id: str + tag_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class MediaBase(BaseModel): + file_name: str + r2_key: Optional[str] = "" + r2_url: Optional[str] = "" + thumbnail_r2_key: Optional[str] = "" + thumbnail_r2_url: Optional[str] = "" + media_type: Optional[str] = "image" + mime_type: Optional[str] = "" + file_size: Optional[int] = 0 + width: Optional[int] = None + height: Optional[int] = None + duration: Optional[float] = None + directory_id: Optional[int] = None + status: Optional[MediaStatus] = MediaStatus.PENDING + error_message: Optional[str] = None + batch_id: Optional[str] = None + tags: Optional[List[int]] = [] + + +class MediaCreate(MediaBase): + pass + + +class MediaUpdate(BaseModel): + file_name: Optional[str] = None + directory_id: Optional[int] = None + status: Optional[MediaStatus] = None + error_message: Optional[str] = None + tags: Optional[List[int]] = None + + +class MediaResponse(MediaBase): + id: int + media_id: str + company_code: str + created_at: datetime + updated_at: datetime + is_deleted: Optional[bool] = False + deleted_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class MediaBatchResponse(BaseModel): + batch_id: str + total: int + successful: int + failed: int + results: List[dict] + + +class MediaBatchCreate(BaseModel): + files: List[str] + directory_id: Optional[int] = None + + +class MediaBatchDeleteRequest(BaseModel): + media_ids: List[str] + + +class MediaBatchUpdateRequest(BaseModel): + media_ids: List[str] + directory_id: Optional[int] = None + tags: Optional[List[int]] = None + + +class MediaBatchTagRequest(BaseModel): + media_ids: List[str] + tag_ids: List[int] + + +class MediaSearchRequest(BaseModel): + keyword: Optional[str] = None + media_type: Optional[str] = None + directory_id: Optional[int] = None + tag_ids: Optional[List[int]] = None + start_date: Optional[str] = None + end_date: Optional[str] = None + min_size: Optional[int] = None + max_size: Optional[int] = None + status: Optional[MediaStatus] = None + + +class MediaDirectoryBase(BaseModel): + name: str + parent_id: Optional[int] = None + path: Optional[str] = "" + level: Optional[int] = 0 + sort_order: Optional[int] = 0 + cover_r2_key: Optional[str] = None + cover_r2_url: Optional[str] = None + description: Optional[str] = None + is_public: Optional[bool] = False + media_count: Optional[int] = 0 + total_size: Optional[int] = 0 + + +class MediaDirectoryCreate(MediaDirectoryBase): + pass + + +class MediaDirectoryUpdate(BaseModel): + name: Optional[str] = None + parent_id: Optional[int] = None + sort_order: Optional[int] = None + cover_r2_key: Optional[str] = None + cover_r2_url: Optional[str] = None + description: Optional[str] = None + is_public: Optional[bool] = None + + +class MediaDirectoryResponse(MediaDirectoryBase): + id: int + company_code: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/schemas/member_level.py b/schemas/member_level.py new file mode 100644 index 0000000000000000000000000000000000000000..98982009ff11139fa45ba2a9f41d9c62fa351893 --- /dev/null +++ b/schemas/member_level.py @@ -0,0 +1,95 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List + +class MemberLevelBase(BaseModel): + level: int + name: str + description: Optional[str] = None + annual_fee: float = 0.0 + discount_price: float = 0.0 + # 独立站部署节点数 + deployment_nodes: int = 1 + # 邮件营销推送数量 + email_marketing_limit: int = 10000 + # 产品SKU数 + product_sku_limit: int = 100 + # 每个SKU可加图片数量 + sku_image_limit: int = 10 + # 图片大小限制 + image_size_limit: int = 5 + # 每个SKU可加视频数量 + sku_video_limit: int = 1 + # 图片空间大小 + image_storage_limit: int = 5 + # 模板选择权限 + template_access: bool = False + # GEO,SEO 关键词营销 + geo_seo_access: bool = False + # 社交平台自动化营销 + social_automation_access: bool = False + # 定制化需求开发 + custom_development_access: bool = False + +class MemberLevelCreate(MemberLevelBase): + pass + +class MemberLevelUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + annual_fee: Optional[float] = None + discount_price: Optional[float] = None + # 独立站部署节点数 + deployment_nodes: Optional[int] = None + # 邮件营销推送数量 + email_marketing_limit: Optional[int] = None + # 产品SKU数 + product_sku_limit: Optional[int] = None + # 每个SKU可加图片数量 + sku_image_limit: Optional[int] = None + # 图片大小限制 + image_size_limit: Optional[int] = None + # 每个SKU可加视频数量 + sku_video_limit: Optional[int] = None + # 图片空间大小 + image_storage_limit: Optional[int] = None + # 模板选择权限 + template_access: Optional[bool] = None + # GEO,SEO 关键词营销 + geo_seo_access: Optional[bool] = None + # 社交平台自动化营销 + social_automation_access: Optional[bool] = None + # 定制化需求开发 + custom_development_access: Optional[bool] = None + +class MemberLevelResponse(MemberLevelBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + +# 公开会员等级数据(简化版,用于首页展示) +class PublicMemberLevel(BaseModel): + level: int + name: str + description: Optional[str] = None + annual_fee: float + discount_price: float + deployment_nodes: int + email_marketing_limit: int + product_sku_limit: int + sku_image_limit: int + image_size_limit: int + sku_video_limit: int + image_storage_limit: int + template_access: bool + geo_seo_access: bool + social_automation_access: bool + custom_development_access: bool + +# 公开会员等级响应 +class PublicMemberLevelResponse(BaseModel): + member_levels: List[PublicMemberLevel] + max_benefit_count: int \ No newline at end of file diff --git a/schemas/membership.py b/schemas/membership.py new file mode 100644 index 0000000000000000000000000000000000000000..7964f786b40efc50be0975c56cb9d66bafdc3e42 --- /dev/null +++ b/schemas/membership.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + +class MembershipBase(BaseModel): + company_code: str + level: int + start_date: datetime + end_date: datetime + +class MembershipCreate(MembershipBase): + pass + +class MembershipUpdate(BaseModel): + level: Optional[int] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + +class MembershipResponse(MembershipBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True \ No newline at end of file diff --git a/schemas/operation_log.py b/schemas/operation_log.py new file mode 100644 index 0000000000000000000000000000000000000000..7654af20a1943886dcd03aa5417fe01f608544e7 --- /dev/null +++ b/schemas/operation_log.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + +class OperationLogBase(BaseModel): + user_id: int + username: str + company_code: Optional[str] = None + module: str + operation_type: str + operation_detail: Optional[str] = None + before_data: Optional[str] = None + after_data: Optional[str] = None + +class OperationLogCreate(OperationLogBase): + pass + +class OperationLogResponse(OperationLogBase): + id: int + created_at: datetime + + class Config: + from_attributes = True diff --git a/schemas/product.py b/schemas/product.py new file mode 100644 index 0000000000000000000000000000000000000000..f6c29129e1eea20270d2569b7b4d319bbc569a7c --- /dev/null +++ b/schemas/product.py @@ -0,0 +1,202 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional, List + +# 商品基础信息 +class ProductBase(BaseModel): + category_id: int = Field(default=0, description="分类 ID") + product_number: str = Field(default="", description="产品货号,11 位 UUID", max_length=11) + model: str = Field(default="", description="产品型号,业务输入", max_length=50) + name: str = Field(default="", description="产品名称", max_length=200) + brand: str = Field(default="", description="品牌名称", max_length=100) + price: float = Field(default=0.0, description="价格") + original_price: float = Field(default=0.0, description="原价") + currency: str = Field(default="USD", description="货币符号", max_length=10) + stock: int = Field(default=0, description="库存") + min_order: int = Field(default=1, description="最小起订量") + unit: str = Field(default="pcs", description="计量单位", max_length=20) + status: int = Field(default=1, description="状态:0-下架,1-上架") + is_new: int = Field(default=0, description="是否新品:0-否,1-是") + is_hot: int = Field(default=0, description="是否热销:0-否,1-是") + is_best_seller: int = Field(default=0, description="是否畅销:0-否,1-是") + sort_order: int = Field(default=0, description="排序顺序") + + # 商品详情字段 + description: str = Field(default="", description="功能描述") + usage: str = Field(default="", description="适用范围") + material: str = Field(default="", description="材质", max_length=200) + parameters: str = Field(default="", description="其他参数说明") + + # SEO 字段 + seo_title_tag: str = Field(default="", description="SEO 页面标题", max_length=200) + seo_meta_keywords: str = Field(default="", description="SEO 元关键词", max_length=500) + + # 产地字段 + country_of_origin: str = Field(default="", description="原产国", max_length=100) + made_in_label: str = Field(default="", description="Made in 标签", max_length=100) + + company_code: str = Field(default="", description="公司编码", max_length=50) + +class ProductCreate(ProductBase): + pass + +class ProductUpdate(BaseModel): + category_id: Optional[int] = None + product_number: Optional[str] = None + model: Optional[str] = None + name: Optional[str] = None + brand: Optional[str] = None + price: Optional[float] = None + original_price: Optional[float] = None + currency: Optional[str] = None + stock: Optional[int] = None + min_order: Optional[int] = None + unit: Optional[str] = None + status: Optional[int] = None + is_new: Optional[int] = None + is_hot: Optional[int] = None + is_best_seller: Optional[int] = None + sort_order: Optional[int] = None + description: Optional[str] = None + usage: Optional[str] = None + material: Optional[str] = None + parameters: Optional[str] = None + seo_title_tag: Optional[str] = None + seo_meta_keywords: Optional[str] = None + country_of_origin: Optional[str] = None + made_in_label: Optional[str] = None + +class ProductResponse(ProductBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + +# 商品媒体 +class ProductMediaBase(BaseModel): + product_number: str = Field(default="", description="产品货号", max_length=11) + media_type: str = Field(default="image", description="媒体类型:image-图片,video-视频", max_length=20) + r2_url: str = Field(default="", description="媒体 URL(R2 原图 URL)", max_length=500) + thumbnail_r2_url: str = Field(default="", description="缩略图 URL(R2 缩略图 URL)", max_length=500) + media_id: str = Field(default="", description="素材ID", max_length=32) + media_alt: str = Field(default="", description="ALT 标签/描述", max_length=200) + is_main: int = Field(default=0, description="是否主图:0-否,1-是") + sort_order: int = Field(default=0, description="排序顺序") + company_code: str = Field(default="", description="公司编码", max_length=50) + +class ProductMediaCreate(ProductMediaBase): + pass + +class ProductMediaUpdate(BaseModel): + media_type: Optional[str] = None + r2_url: Optional[str] = None + thumbnail_r2_url: Optional[str] = None + media_id: Optional[str] = None + media_alt: Optional[str] = None + is_main: Optional[int] = None + sort_order: Optional[int] = None + +class ProductMediaResponse(ProductMediaBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +# 商品认证 +class ProductCertificationBase(BaseModel): + product_number: str = Field(default="", description="产品货号", max_length=11) + certification_type: str = Field(default="", description="认证类型", max_length=50) + r2_url: str = Field(default="", description="认证文件 URL(R2 URL)", max_length=500) + thumbnail_r2_url: str = Field(default="", description="认证缩略图路径(R2 URL)", max_length=500) + media_id: str = Field(default="", description="素材ID", max_length=32) + company_code: str = Field(default="", description="公司编码", max_length=50) + +class ProductCertificationCreate(ProductCertificationBase): + pass + +class ProductCertificationUpdate(BaseModel): + certification_type: Optional[str] = None + r2_url: Optional[str] = None + thumbnail_r2_url: Optional[str] = None + media_id: Optional[str] = None + +class ProductCertificationResponse(ProductCertificationBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +# 商品包装 +class ProductPackagingBase(BaseModel): + product_number: str = Field(default="", description="产品货号", max_length=11) + model: str = Field(default="", description="型号", max_length=50) + length: float = Field(default=0.0, description="长度 (cm)") + width: float = Field(default=0.0, description="宽度 (cm)") + height: float = Field(default=0.0, description="高度 (cm)") + volume: float = Field(default=0.0, description="体积 (cm³)") + weight: float = Field(default=0.0, description="重量 (g)") + company_code: str = Field(default="", description="公司编码", max_length=50) + +class ProductPackagingCreate(ProductPackagingBase): + pass + +class ProductPackagingUpdate(BaseModel): + model: Optional[str] = None + length: Optional[float] = None + width: Optional[float] = None + height: Optional[float] = None + volume: Optional[float] = None + weight: Optional[float] = None + +class ProductPackagingResponse(ProductPackagingBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +# 商品详情(包含所有关联数据) +class ProductDetailResponse(ProductResponse): + media: List[ProductMediaResponse] = [] + certifications: List[ProductCertificationResponse] = [] + packaging: List[ProductPackagingResponse] = [] + + class Config: + from_attributes = True + +# 公司 R2 配置 +class CompanyR2ConfigBase(BaseModel): + company_code: str = Field(..., description="公司编码") + r2_account_id: str = Field(..., description="R2 账户 ID") + r2_access_key_id: str = Field(..., description="R2 访问密钥 ID") + r2_secret_access_key: str = Field(..., description="R2 密钥") + r2_bucket_name: str = Field(..., description="R2 桶名称") + r2_public_url: str = Field(..., description="R2 公共访问 URL") + r2_enabled: int = Field(..., description="是否启用 R2:0-禁用,1-启用") + +class CompanyR2ConfigCreate(CompanyR2ConfigBase): + pass + +class CompanyR2ConfigUpdate(BaseModel): + r2_account_id: Optional[str] = None + r2_access_key_id: Optional[str] = None + r2_secret_access_key: Optional[str] = None + r2_bucket_name: Optional[str] = None + r2_public_url: Optional[str] = None + r2_enabled: Optional[int] = None + +class CompanyR2ConfigResponse(CompanyR2ConfigBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + company_name: Optional[str] = None + + class Config: + from_attributes = True diff --git a/schemas/template.py b/schemas/template.py new file mode 100644 index 0000000000000000000000000000000000000000..46112dd12afb0460417a2bc89cc0808be939abab --- /dev/null +++ b/schemas/template.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List + +class TemplateBase(BaseModel): + key: str + name: str + description: Optional[str] = None + color: str + features: Optional[List[str]] = None + applicable_scene: Optional[str] = None + regions: Optional[List[str]] = None + +class TemplateCreate(TemplateBase): + pass + +class TemplateUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + features: Optional[List[str]] = None + applicable_scene: Optional[str] = None + regions: Optional[List[str]] = None + +class TemplateResponse(TemplateBase): + id: int + select_count: int = 0 + use_count: int = 0 + status: int = 0 + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class TemplateStatusUpdate(BaseModel): + status: int \ No newline at end of file diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000000000000000000000000000000000000..f52d0c200ebaf3f76e869ab899d4f0a7ea74dbf4 --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional + +class UserBase(BaseModel): + username: str + email: EmailStr + company_code: Optional[str] = None + +class UserCreate(UserBase): + password: Optional[str] = None + is_superadmin: Optional[bool] = False + +class UserLogin(BaseModel): + username: str + password: str + +class UserResponse(UserBase): + id: int + is_superadmin: bool + + class Config: + from_attributes = True + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + username: Optional[str] = None diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..37a851efd25d9296baff6e5b5b870897c512c20c --- /dev/null +++ b/start.ps1 @@ -0,0 +1,22 @@ +# 后端服务启动脚本 +param( + [int]$Port = 8000 +) + +Write-Host "正在检查端口 $Port 是否被占用..." -ForegroundColor Cyan + +# 查找占用端口的进程 +$process = Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -First 1 + +if ($process) { + Write-Host "端口 $Port 被进程 PID: $process 占用" -ForegroundColor Yellow + Write-Host "正在终止进程..." -ForegroundColor Yellow + Stop-Process -Id $process -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + Write-Host "进程已终止" -ForegroundColor Green +} else { + Write-Host "端口 $Port 可用" -ForegroundColor Green +} + +Write-Host "`n正在启动后端服务..." -ForegroundColor Cyan +python main.py diff --git a/utils/__pycache__/auth.cpython-314.pyc b/utils/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a893257701294d70ae752bb5534d8672cde84d8e Binary files /dev/null and b/utils/__pycache__/auth.cpython-314.pyc differ diff --git a/utils/__pycache__/file_manager.cpython-314.pyc b/utils/__pycache__/file_manager.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..337a7260748f072bb6177c34fc121b1025804044 Binary files /dev/null and b/utils/__pycache__/file_manager.cpython-314.pyc differ diff --git a/utils/__pycache__/r2_uploader.cpython-314.pyc b/utils/__pycache__/r2_uploader.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2e65cf708b0812ab2555dda989288fec4a7af1ef Binary files /dev/null and b/utils/__pycache__/r2_uploader.cpython-314.pyc differ diff --git a/utils/auth.py b/utils/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..db470a16f8158d5e580581aba8c78ef1bb0e13ca --- /dev/null +++ b/utils/auth.py @@ -0,0 +1,50 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from dotenv import load_dotenv +import os + +# 加载环境变量 +load_dotenv() + +# 密码加密上下文 +pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + +# JWT 配置 +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key") +ALGORITHM = os.getenv("ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "600")) + +def verify_password(plain_password, hashed_password): + """验证密码""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + """获取密码哈希值""" + # 限制密码长度不超过72字节,这是bcrypt的限制 + if len(password) > 72: + password = password[:72] + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """创建访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def decode_token(token: str): + """解码令牌""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + return None + return username + except JWTError: + return None diff --git a/utils/file_manager.py b/utils/file_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..09bdd4f1947d5a52b7a278712220993f9ab739e9 --- /dev/null +++ b/utils/file_manager.py @@ -0,0 +1,90 @@ +import asyncio +from typing import Optional, Dict + +# 导入 R2 上传器 +from utils.r2_uploader import get_r2_uploader, R2Config + + +async def save_and_upload_to_r2( + file, + company_code: str, + product_number: str, + file_type: str, # 'media' 或 'cert' + r2_config: Optional[R2Config] = None, + sub_type: Optional[str] = None # 认证类型,如 'ce', 'rohs' 等 +) -> Dict: + """ + 直接上传文件到 R2(不再保存到本地) + + 参数: + file: 上传的文件对象 + company_code: 公司编码 + product_number: 商品货号 + file_type: 文件类型 ('media' 或 'cert') + r2_config: R2 配置对象(如果为 None,则上传失败) + sub_type: 子类型(认证类型) + + 返回: + dict: { + 'file_path': str, # 空字符串(保持兼容性) + 'file_name': str, # 文件名 + 'full_path': str, # 空字符串(保持兼容性) + 'server_url': str, # 空字符串(保持兼容性) + 'r2_url': str, # R2 原图 URL(media_url 字段使用) + 'thumbnail_url': str, # R2 缩略图 URL(thumbnail_path 字段使用) + 'r2_key': str, # R2 对象键 + 'thumbnail_key': str # R2 缩略图对象键 + } + """ + # 读取文件内容 + if hasattr(file, 'file'): + # UploadFile 对象,file.file 是 SpooledTemporaryFile + file_content = file.file.read() + else: + # 其他文件对象 + file_content = file.read() + + # 获取原始文件名 + original_filename = getattr(file, 'filename', 'unknown') + + # 尝试上传到 R2 + r2_url = '' + thumbnail_url = '' + r2_key = '' + thumbnail_key = '' + + if r2_config: + r2_uploader = get_r2_uploader() + if r2_uploader.is_available(r2_config): + try: + r2_result = await r2_uploader.upload_file( + file_buffer=file_content, + company_code=company_code, + product_number=product_number, + file_type=file_type, + filename=original_filename, + config=r2_config, + sub_type=sub_type, + generate_thumbnail=(file_type == 'media') + ) + r2_url = r2_result['r2_url'] + thumbnail_url = r2_result['thumbnail_url'] + r2_key = r2_result['r2_key'] + thumbnail_key = r2_result['thumbnail_key'] + except Exception as e: + print(f"R2 上传失败: {e}") + raise + else: + print("R2 配置未提供,上传失败") + raise ValueError("R2 配置未提供") + + return { + 'file_path': '', + 'file_name': original_filename, + 'full_path': '', + 'server_url': '', + 'r2_url': r2_url, + 'thumbnail_url': thumbnail_url, + 'r2_key': r2_key, + 'thumbnail_key': thumbnail_key + } diff --git a/utils/ip_location.py b/utils/ip_location.py new file mode 100644 index 0000000000000000000000000000000000000000..674f2d5052102acbe910e3ada442078afffe0375 --- /dev/null +++ b/utils/ip_location.py @@ -0,0 +1,71 @@ +import logging +from typing import Dict, Optional + +logger = logging.getLogger(__name__) + + +class IPLocationService: + """IP地理位置解析服务(降级版本)""" + + def __init__(self): + pass + + def get_location(self, ip_address: str) -> Dict[str, Optional[str]]: + """ + 根据IP地址获取地理位置信息 + + Returns: + { + "country": "国家", + "region": "地区/省份", + "city": "城市", + "latitude": "纬度", + "longitude": "经度" + } + """ + try: + # 如果是本地IP,返回默认值 + if self._is_private_ip(ip_address): + return { + "country": "中国", + "region": "本地", + "city": "本地", + "latitude": "39.9042", + "longitude": "116.4074" + } + + # 降级:返回默认位置 + return self._get_default_location() + + except Exception as e: + logger.error(f"IP地理位置解析异常: {ip_address}, 错误: {str(e)}") + return self._get_default_location() + + def _is_private_ip(self, ip: str) -> bool: + """检查是否为私有IP地址""" + if ip.startswith("127.") or ip.startswith("192.168.") or ip.startswith("10."): + return True + if ip.startswith("172."): + parts = ip.split(".") + if len(parts) >= 2: + try: + second_octet = int(parts[1]) + if 16 <= second_octet <= 31: + return True + except ValueError: + pass + return False + + def _get_default_location(self) -> Dict[str, Optional[str]]: + """返回默认位置""" + return { + "country": None, + "region": None, + "city": None, + "latitude": None, + "longitude": None + } + + +# 全局实例 +ip_location_service = IPLocationService() diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..bff0f73c1d7f56df3a39b4f32742c6cb80cbc23d --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,54 @@ +import logging +import sys +from pathlib import Path +from logging.handlers import TimedRotatingFileHandler +from middleware.trace import get_trace_id + +# 确保日志目录存在 +LOG_DIR = Path(__file__).parent.parent / "logs" +LOG_DIR.mkdir(exist_ok=True) + +# 自定义日志格式类 +class TraceFormatter(logging.Formatter): + def format(self, record): + # 获取当前的 trace_id + trace_id = get_trace_id() + # 在日志消息中添加 trace_id + if trace_id: + record.msg = f"[trace_id={trace_id}] {record.msg}" + return super().format(record) + +# 配置日志记录器 +logger = logging.getLogger("backend") +logger.setLevel(logging.INFO) + +# 创建按日期分片的文件处理器 +# 每天午夜轮换,保留 30 天的日志 +file_handler = TimedRotatingFileHandler( + LOG_DIR / "backend.log", + when="midnight", + interval=1, + backupCount=30, + encoding="utf-8" +) +file_handler.setLevel(logging.INFO) +file_handler.suffix = "%Y-%m-%d.log" + +# 创建控制台处理器 +console_handler = logging.StreamHandler(sys.stdout) +console_handler.setLevel(logging.INFO) + +# 定义日志格式 +formatter = TraceFormatter( + '%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s' +) +file_handler.setFormatter(formatter) +console_handler.setFormatter(formatter) + +# 添加处理器到记录器 +if not logger.handlers: + logger.addHandler(file_handler) + logger.addHandler(console_handler) + +# 导出logger +__all__ = ["logger"] \ No newline at end of file diff --git a/utils/r2_uploader.py b/utils/r2_uploader.py new file mode 100644 index 0000000000000000000000000000000000000000..ba5ebe62994ba313624af1440e5cb2a1eaeb590a --- /dev/null +++ b/utils/r2_uploader.py @@ -0,0 +1,759 @@ +import os +import asyncio +import boto3 +from botocore.exceptions import ClientError +from pathlib import Path +from typing import List, Dict, Optional, Tuple +from PIL import Image +import io +import hashlib +import threading +from datetime import datetime, timedelta +from dotenv import load_dotenv + +# 配置缓存 +_config_cache = {} +_cache_lock = threading.Lock() +_CACHE_TTL = timedelta(minutes=30) # 缓存 30 分钟 + +# 全局S3客户端缓存,按公司编码管理 +# 格式: {company_code: {"client": s3_client, "created_at": datetime}} +GLOBAL_S3_CLIENTS = {} +# 客户端过期时间(秒) +CLIENT_EXPIRY_SECONDS = 24 * 3600 # 24小时 +# 客户端缓存锁 +_client_lock = threading.Lock() + + +# ===================== 先定义类,再使用!===================== +class R2Config: + """R2 配置对象""" + def __init__(self, config_data: Dict): + self.account_id = config_data.get('r2_account_id', '') + self.access_key_id = config_data.get('r2_access_key_id', '') + self.secret_access_key = config_data.get('r2_secret_access_key', '') + self.bucket_name = config_data.get('r2_bucket_name', 'ymt-images') + self.public_url = config_data.get('r2_public_url', '') + self.enabled = config_data.get('r2_enabled', 1) == 1 + self.cached_at = datetime.now() + + def is_expired(self) -> bool: + """检查缓存是否过期""" + return datetime.now() - self.cached_at > _CACHE_TTL + + +def get_r2_config_from_env() -> Optional[R2Config]: + """从 .env 文件获取 R2 配置""" + # 尝试加载 .env 文件 + try: + load_dotenv() + except: + pass + + r2_account_id = os.getenv('R2_ACCOUNT_ID', '') + r2_access_key_id = os.getenv('R2_ACCESS_KEY_ID', '') + r2_secret_access_key = os.getenv('R2_SECRET_ACCESS_KEY', '') + r2_bucket_name = os.getenv('R2_BUCKET_NAME', 'yomaton') + r2_public_url = os.getenv('R2_PUBLIC_URL', '') + r2_enabled = os.getenv('R2_ENABLED', 'true').lower() == 'true' + + if r2_enabled and r2_account_id and r2_access_key_id and r2_secret_access_key: + return R2Config({ + 'r2_account_id': r2_account_id, + 'r2_access_key_id': r2_access_key_id, + 'r2_secret_access_key': r2_secret_access_key, + 'r2_bucket_name': r2_bucket_name, + 'r2_public_url': r2_public_url, + 'r2_enabled': 1 if r2_enabled else 0 + }) + + return None + + +class R2Uploader: + """ + Cloudflare R2 上传工具类 + """ + + def __init__(self, config: R2Config, company_code: str = "default"): + self.config = config + self.company_code = company_code + self._cleanup_expired_clients() + + def _cleanup_expired_clients(self): + """清理过期的客户端""" + with _client_lock: + expired_companies = [] + for company_code, client_info in GLOBAL_S3_CLIENTS.items(): + if (datetime.utcnow() - client_info["created_at"]).total_seconds() > CLIENT_EXPIRY_SECONDS: + expired_companies.append(company_code) + + for company_code in expired_companies: + if company_code != '0000' and company_code != 'default': + del GLOBAL_S3_CLIENTS[company_code] + print(f"清理过期的S3客户端: {company_code}") + + def _get_config_from_db(self, company_code: str) -> Optional[R2Config]: + """ + 从数据库获取 R2 配置 + + 注意:这里需要在有数据库会话的上下文调用 + 实际使用时通过 get_r2_uploader_for_company() 传入配置 + """ + return None + + def get_client(self): + """ + 获取 R2 S3 客户端,按公司编码全局复用 + 优先级:当前公司编码 -> '0000' -> 'default' + """ + if not self.config.enabled or not self.config.account_id or not self.config.access_key_id or not self.config.secret_access_key: + return None + + # 清理过期客户端 + self._cleanup_expired_clients() + + # 检查顺序:当前公司 -> 0000 -> default + client_codes = [self.company_code, '0000', 'default'] + + with _client_lock: + for code in client_codes: + if code in GLOBAL_S3_CLIENTS: + client_info = GLOBAL_S3_CLIENTS[code] + time_diff = (datetime.utcnow() - client_info["created_at"]).total_seconds() + if time_diff < CLIENT_EXPIRY_SECONDS: + print(f"复用S3客户端: {code} (优先级: {client_codes.index(code) + 1})") + # 如果不是当前公司的客户端,为当前公司创建一个引用 + if code != self.company_code: + GLOBAL_S3_CLIENTS[self.company_code] = { + "client": client_info["client"], + "created_at": datetime.utcnow() + } + print(f"为当前公司 {self.company_code} 创建客户端引用") + return client_info["client"] + else: + # 客户端已过期,删除 + del GLOBAL_S3_CLIENTS[code] + print(f"S3客户端已过期,删除: {code}") + + # 创建新的客户端 + try: + client = boto3.client( + 's3', + endpoint_url=f'https://{self.config.account_id}.r2.cloudflarestorage.com', + aws_access_key_id=self.config.access_key_id, + aws_secret_access_key=self.config.secret_access_key + ) + + # 缓存客户端 + with _client_lock: + GLOBAL_S3_CLIENTS[self.company_code] = { + "client": client, + "created_at": datetime.utcnow() + } + # 同时缓存为 'default'(如果是从环境变量加载的配置) + if self.company_code == 'default': + pass # 已经缓存为 default + elif self.company_code == '0000': + # 0000 公司的配置也缓存为 default + GLOBAL_S3_CLIENTS['default'] = { + "client": client, + "created_at": datetime.utcnow() + } + + print(f"创建新的S3客户端: {self.company_code}") + return client + except Exception as e: + print(f"R2 客户端初始化失败: {e}") + return None + + def is_available(self) -> bool: + """检查 R2 是否可用""" + if not self.config or not self.config.enabled: + return False + return self.get_client() is not None + + def generate_r2_key(self, company_code: str, product_number: str, file_type: str, filename: str, sub_type: Optional[str] = None) -> str: + """ + 生成 R2 对象键(路径) + + 格式: {company_code}/{product_number}/{file_type}/{sub_type}/{filename} + """ + key_parts = [company_code, product_number, file_type] + if sub_type: + key_parts.append(sub_type) + key_parts.append(filename) + return '/'.join(key_parts) + + def generate_public_url(self, r2_key: str) -> str: + """ + 生成 R2 公共访问 URL + """ + if not self.config or not self.config.public_url: + return '' + return f"{self.config.public_url.rstrip('/')}/{r2_key.lstrip('/')}" + + def generate_thumbnail(self, file_data: bytes, max_width: int = 400, max_height: int = 400) -> Tuple[bytes, str]: + """ + 从字节数据生成缩略图 + + 返回: (缩略图二进制数据, 文件扩展名) + """ + try: + img = Image.open(io.BytesIO(file_data)) + + # 计算缩放尺寸 + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + + # 保存为字节流 + img_byte_arr = io.BytesIO() + + # 确定保存格式 + ext = '.jpg' # 默认使用JPEG + if img.mode in ['RGBA', 'P']: + img = img.convert('RGB') + ext = '.jpg' + elif img.mode == 'LA': + img = img.convert('RGBA') + ext = '.png' + + save_format = 'JPEG' if ext in ['.jpg', '.jpeg'] else ext[1:].upper() + img.save(img_byte_arr, format=save_format, quality=85) + img_byte_arr.seek(0) + + return img_byte_arr.read(), ext + except Exception as e: + print(f"缩略图生成失败: {e}") + return file_data, '.jpg' + + def generate_md5_key(self, file_data: bytes, filename: str) -> str: + """ + 使用文件MD5生成R2键 + + 参数: + file_data: 文件字节数据 + filename: 原始文件名 + + 返回: + R2键(MD5命名) + """ + # 计算文件MD5 + hash_md5 = hashlib.md5() + hash_md5.update(file_data) + file_hash = hash_md5.hexdigest() + + # 获取文件扩展名 + ext = Path(filename).suffix.lower() + + # 生成MD5命名的R2键 + return f"{file_hash}{ext}" + + async def upload_file( + self, + file_buffer: bytes, + file_name: str, + content_type: Optional[str] = None, + generate_thumbnail: bool = True, + max_retries: int = 3 + ) -> str: + """ + 上传单个文件到 R2 + + 参数: + file_buffer: 文件字节数据 + file_name: 文件名(完整R2键,包含路径) + content_type: MIME类型 + generate_thumbnail: 是否生成缩略图 + max_retries: 最大重试次数 + + 返回: + R2 URL + """ + if not self.is_available(): + return '' + + s3_client = self.get_client() + + # 直接使用传入的文件名作为R2键 + r2_key = file_name + + # 上传原图(带重试机制) + for attempt in range(max_retries): + try: + s3_client.put_object( + Bucket=self.config.bucket_name, + Key=r2_key, + Body=file_buffer, + ContentType=content_type or 'application/octet-stream' + ) + print(f"R2 上传成功: {r2_key}") + break + except ClientError as e: + error_code = e.response.get('Error', {}).get('Code') + # 对于可重试的错误,进行重试 + if error_code in ['RequestTimeout', 'ConnectionError', 'ServiceUnavailable'] and attempt < max_retries - 1: + print(f"R2 上传失败 (尝试 {attempt + 1}/{max_retries}): {e}") + await asyncio.sleep(1 * (attempt + 1)) # 指数退避 + else: + print(f"R2 上传失败: {e}") + raise + + # 生成并上传缩略图(如果是图片,但这里不处理缩略图,因为upload_thumbnail已被单独调用 + # 缩略图将在media.py中单独处理 + + return self.generate_public_url(r2_key) + + async def batch_upload_files( + self, + files: List[Dict[str, any]], + max_concurrency: int = 10 # 增加并发数 + ) -> Dict[str, str]: + """ + 批量上传多个文件到 R2 + + 参数: + files: 文件列表,每个文件包含 {'file_buffer': bytes, 'file_name': str, 'content_type': Optional[str]} + max_concurrency: 最大并发数 + + 返回: + 字典,键为文件名,值为R2 URL + """ + if not self.is_available(): + return {} + + if not files: + return {} + + # 获取S3客户端,避免每次上传都创建新连接 + s3_client = self.get_client() + if not s3_client: + return {} + + # 限制并发数 + semaphore = asyncio.Semaphore(max_concurrency) + results = {} + + async def upload_file_async(file_info): + async with semaphore: + file_buffer = file_info['file_buffer'] + file_name = file_info['file_name'] + content_type = file_info.get('content_type') or 'application/octet-stream' + + try: + # 对于大文件使用分块上传 + file_size = len(file_buffer) + if file_size > 5 * 1024 * 1024: # 5MB以上使用分块上传 + url = await self._upload_large_file( + s3_client, file_buffer, file_name, content_type + ) + else: + # 小文件直接上传 + s3_client.put_object( + Bucket=self.config.bucket_name, + Key=file_name, + Body=file_buffer, + ContentType=content_type + ) + url = self.generate_public_url(file_name) + + results[file_name] = url + print(f"✅ 批量上传成功: {file_name} ({file_size/1024/1024:.2f}MB)") + except ClientError as e: + error_code = e.response.get('Error', {}).get('Code') + print(f"❌ 批量上传失败: {file_name}, 错误: {error_code} - {e}") + results[file_name] = '' + except Exception as e: + print(f"❌ 批量上传失败: {file_name}, 错误: {e}") + results[file_name] = '' + + # 并行上传文件 + tasks = [upload_file_async(file) for file in files] + await asyncio.gather(*tasks) + + return results + + async def _upload_large_file( + self, + s3_client, + file_buffer: bytes, + file_name: str, + content_type: str + ) -> str: + """ + 分块上传大文件 + + 参数: + s3_client: S3客户端 + file_buffer: 文件字节数据 + file_name: 文件名 + content_type: MIME类型 + + 返回: + R2 URL + """ + # 初始化分块上传 + response = s3_client.create_multipart_upload( + Bucket=self.config.bucket_name, + Key=file_name, + ContentType=content_type + ) + upload_id = response['UploadId'] + + # 分块大小:5MB + part_size = 5 * 1024 * 1024 + file_size = len(file_buffer) + parts = [] + + try: + # 上传分块 + for i in range(0, file_size, part_size): + part_number = (i // part_size) + 1 + part_data = file_buffer[i:i+part_size] + + response = s3_client.upload_part( + Bucket=self.config.bucket_name, + Key=file_name, + UploadId=upload_id, + PartNumber=part_number, + Body=part_data + ) + + parts.append({ + 'PartNumber': part_number, + 'ETag': response['ETag'] + }) + + # 完成分块上传 + s3_client.complete_multipart_upload( + Bucket=self.config.bucket_name, + Key=file_name, + UploadId=upload_id, + MultipartUpload={'Parts': parts} + ) + + return self.generate_public_url(file_name) + except Exception as e: + # 取消分块上传 + s3_client.abort_multipart_upload( + Bucket=self.config.bucket_name, + Key=file_name, + UploadId=upload_id + ) + raise + + async def batch_upload_thumbnails( + self, + thumbnails: List[Dict[str, any]], + max_concurrency: int = 10 # 增加并发数 + ) -> Dict[str, str]: + """ + 批量上传多个缩略图到 R2 + + 参数: + thumbnails: 缩略图列表,每个缩略图包含 {'file_buffer': bytes, 'file_name': str, 'width': Optional[int], 'height': Optional[int]} + max_concurrency: 最大并发数 + + 返回: + 字典,键为文件名,值为缩略图R2 URL + """ + if not self.is_available(): + return {} + + if not thumbnails: + return {} + + # 获取S3客户端,避免每次上传都创建新连接 + s3_client = self.get_client() + if not s3_client: + return {} + + # 限制并发数 + semaphore = asyncio.Semaphore(max_concurrency) + results = {} + + async def upload_thumbnail_async(thumbnail_info): + async with semaphore: + file_buffer = thumbnail_info['file_buffer'] + file_name = thumbnail_info['file_name'] + width = thumbnail_info.get('width', 200) + height = thumbnail_info.get('height', 200) + + try: + # 直接使用S3客户端上传,避免重复创建客户端 + s3_client.put_object( + Bucket=self.config.bucket_name, + Key=file_name, + Body=file_buffer, + ContentType='image/jpeg' + ) + url = self.generate_public_url(file_name) + results[file_name] = url + print(f"✅ 批量缩略图上传成功: {file_name} ({len(file_buffer)/1024:.2f}KB)") + except ClientError as e: + error_code = e.response.get('Error', {}).get('Code') + print(f"❌ 批量缩略图上传失败: {file_name}, 错误: {error_code} - {e}") + results[file_name] = '' + except Exception as e: + print(f"❌ 批量缩略图上传失败: {file_name}, 错误: {e}") + results[file_name] = '' + + # 并行上传缩略图 + tasks = [upload_thumbnail_async(thumbnail) for thumbnail in thumbnails] + await asyncio.gather(*tasks) + + return results + + async def upload_thumbnail( + self, + file_buffer: bytes, + file_name: str, + width: int = 200, + height: int = 200 + ) -> str: + """ + 上传缩略图到 R2 + + 参数: + file_buffer: 文件字节数据 + file_name: 文件名(完整R2键,包含路径) + width: 缩略图宽度 + height: 缩略图高度 + + 返回: + 缩略图R2 URL + """ + if not self.is_available(): + return '' + + s3_client = self.get_client() + + # 生成缩略图 + thumbnail_data, ext = self.generate_thumbnail(file_buffer, width, height) + + # 直接使用传入的文件名作为缩略图R2键 + thumbnail_r2_key = file_name + + # 上传缩略图 + try: + s3_client.put_object( + Bucket=self.config.bucket_name, + Key=thumbnail_r2_key, + Body=thumbnail_data, + ContentType='image/jpeg' + ) + print(f"R2 缩略图上传成功: {thumbnail_r2_key}") + except ClientError as e: + print(f"R2 缩略图上传失败: {e}") + raise + + return self.generate_public_url(thumbnail_r2_key) + + async def delete_file(self, r2_key: str) -> bool: + """ + 从 R2 删除文件 + + 参数: + r2_key: R2 对象键 + + 返回: + 是否删除成功 + """ + print(f" [R2Delete] 准备删除: {r2_key}") + + if not self.is_available(): + print(f" [R2Delete] ❌ R2不可用") + return False + + if not r2_key: + print(f" [R2Delete] ❌ R2 Key为空") + return False + + s3_client = self.get_client() + if not s3_client: + print(f" [R2Delete] ❌ 无法获取R2客户端") + return False + + try: + print(f" [R2Delete] 调用delete_object...") + response = s3_client.delete_object( + Bucket=self.config.bucket_name, + Key=r2_key + ) + print(f" [R2Delete] ✅ 删除成功: {r2_key}") + print(f" [R2Delete] 响应: {response}") + return True + except ClientError as e: + print(f" [R2Delete] ❌ 删除失败: {e}") + print(f" [R2Delete] 错误代码: {e.response.get('Error', {}).get('Code')}") + print(f" [R2Delete] 错误消息: {e.response.get('Error', {}).get('Message')}") + return False + except Exception as e: + print(f" [R2Delete] ❌ 未知错误: {e}") + import traceback + traceback.print_exc() + return False + + def _get_content_type(self, filename: str) -> str: + """根据文件名获取 Content-Type""" + ext = Path(filename).suffix.lower() + content_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.pdf': 'application/pdf' + } + return content_types.get(ext, 'application/octet-stream') + + +# 全局实例 +_config_cache = {} +_cache_lock = threading.Lock() + + +# 向后兼容的方法 - 旧API +class CompatibleR2Uploader: + """向后兼容的R2上传器包装类""" + def __init__(self): + self.uploader = None + + def is_available(self, config: R2Config) -> bool: + """检查是否可用 - 向后兼容""" + if not config or not config.enabled: + return False + return config.account_id and config.access_key_id and config.secret_access_key + + async def upload_file(self, file_path: str, company_code: str, + product_number: str, file_type: str, + filename: str, config: R2Config, + sub_type: Optional[str] = None, + generate_thumbnail: bool = True) -> Dict: + """旧的上传文件方法 - 向后兼容""" + import hashlib + from pathlib import Path + + self.uploader = R2Uploader(config) + if not self.uploader.is_available(): + return { + 'r2_url': '', + 'thumbnail_url': '', + 'r2_key': '', + 'thumbnail_key': '' + } + + # 读取文件 + with open(file_path, 'rb') as f: + file_buffer = f.read() + + # 计算MD5 + hash_md5 = hashlib.md5() + hash_md5.update(file_buffer) + file_hash = hash_md5.hexdigest() + file_extension = Path(filename).suffix.lower() + + # 构建R2键 + r2_key = f"{company_code}/{file_hash}{file_extension}" + thumbnail_key = f"{company_code}/thumb_{file_hash}{file_extension}" + + # 上传原图 + r2_url = await self.uploader.upload_file( + file_buffer=file_buffer, + file_name=r2_key, + content_type=self.uploader._get_content_type(filename), + generate_thumbnail=False + ) + + thumbnail_url = '' + if generate_thumbnail: + thumbnail_url = await self.uploader.upload_thumbnail( + file_buffer=file_buffer, + file_name=thumbnail_key, + width=200, + height=200 + ) + + return { + 'r2_url': r2_url, + 'thumbnail_url': thumbnail_url, + 'r2_key': r2_key, + 'thumbnail_key': thumbnail_key + } + + async def delete_file(self, r2_key: str, config: R2Config) -> bool: + """删除文件 - 向后兼容""" + if not r2_key: + return False + + self.uploader = R2Uploader(config) + if not self.uploader.is_available(): + return False + + return await self.uploader.delete_file(r2_key) + + async def batch_upload_files( + self, + files: List[Dict[str, any]], + config: R2Config, + max_concurrency: int = 5 + ) -> Dict[str, str]: + """批量上传多个文件 - 向后兼容""" + self.uploader = R2Uploader(config) + if not self.uploader.is_available(): + return {} + + return await self.uploader.batch_upload_files(files, max_concurrency) + + async def batch_upload_thumbnails( + self, + thumbnails: List[Dict[str, any]], + config: R2Config, + max_concurrency: int = 5 + ) -> Dict[str, str]: + """批量上传多个缩略图 - 向后兼容""" + self.uploader = R2Uploader(config) + if not self.uploader.is_available(): + return {} + + return await self.uploader.batch_upload_thumbnails(thumbnails, max_concurrency) + + +# 全局向后兼容的实例 +_compatible_uploader = None + +def get_r2_uploader(config: R2Config = None, company_code: str = "default") -> any: + """获取 R2 上传器实例 - 支持新旧API""" + if config is not None: + # 新API - 返回R2Uploader实例,按公司编码管理 + return R2Uploader(config, company_code) + else: + # 旧API - 返回向后兼容的实例 + global _compatible_uploader + if _compatible_uploader is None: + _compatible_uploader = CompatibleR2Uploader() + return _compatible_uploader + + +def get_cached_config(company_code: str) -> Optional[R2Config]: + """从缓存获取配置""" + with _cache_lock: + config = _config_cache.get(company_code) + if config and not config.is_expired(): + return config + return None + + +def set_cached_config(company_code: str, config: R2Config): + """设置缓存配置""" + with _cache_lock: + _config_cache[company_code] = config + + +def clear_cached_config(company_code: str): + """清除缓存配置""" + with _cache_lock: + if company_code in _config_cache: + del _config_cache[company_code] + print(f"已清除公司 {company_code} 的 R2 配置缓存")