commit 31af6e07e29e8290de96655ee221a1aee5fcbee2
Author: Cxx0822 <1556464090@qq.com>
Date: Wed Sep 9 18:42:37 2026 +0800
feat: 初始化工程
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7ddac70
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,68 @@
+# Python 字节码文件
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C 扩展
+*.so
+
+# 分发/打包
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# 虚拟环境
+venv/
+env/
+ENV/
+.env
+.venv
+
+# 测试
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+
+# Django 相关
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+media/
+
+# PyCharm IDE
+.idea/
+*.iml
+*.iws
+*.ipr
+
+# VS Code
+.vscode/
+*.code-workspace
+.history/
+
+# 其他
+.DS_Store
+
+logs/
+packages/
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..15d6296
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/patch-service.iml b/.idea/patch-service.iml
new file mode 100644
index 0000000..e8508a1
--- /dev/null
+++ b/.idea/patch-service.iml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000..97edfec
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1788917189828
+
+
+ 1788917189828
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/config/database.py b/config/database.py
new file mode 100644
index 0000000..aede70e
--- /dev/null
+++ b/config/database.py
@@ -0,0 +1,26 @@
+import os
+
+from dotenv import load_dotenv
+from sqlalchemy import create_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+
+load_dotenv()
+
+DATABASE_URL = f"mysql+pymysql://root:{os.getenv('DB_PASSWORD')}@{os.getenv('DB_HOST')}:3306/patch"
+
+engine = create_engine(url=DATABASE_URL, pool_pre_ping=True, pool_recycle=3600)
+
+# 会话工厂
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+# ORM基类
+Base = declarative_base()
+
+
+def get_db():
+ # 创建数据库会话实例
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/config/rustfs.py b/config/rustfs.py
new file mode 100644
index 0000000..733b07e
--- /dev/null
+++ b/config/rustfs.py
@@ -0,0 +1,18 @@
+import os
+
+import boto3
+from botocore.client import Config
+from dotenv import load_dotenv
+
+load_dotenv()
+
+access_key = 'Qc0pkBUAs5zEGgwWbYxr'
+secret_access = '9LQmdBYlagrCh5Iq0K1264UHRzfZ7VeFiDNGvsnb'
+
+s3 = boto3.client('s3',
+ endpoint_url=f'http://{os.getenv("RUSTFS_HOST")}:{os.getenv("RUSTFS_PORT")}',
+ aws_access_key_id=access_key,
+ aws_secret_access_key=secret_access,
+ config=Config(signature_version='s3v4'),
+ region_name='cn-east-1'
+ )
diff --git a/id_generator/__init__.py b/id_generator/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/id_generator/generator.py b/id_generator/generator.py
new file mode 100644
index 0000000..fc995d7
--- /dev/null
+++ b/id_generator/generator.py
@@ -0,0 +1,38 @@
+"""
+雪花算法生成器IdGenerator
+"""
+
+# !/usr/bin/python
+# coding=UTF-8
+
+
+from . import options
+from . import snowflake_m1
+
+
+class DefaultIdGenerator:
+ """
+ ID生成器
+ """
+
+ def __init__(self):
+ self.snowflake = None
+
+ def set_id_generator(self, option: options.IdGeneratorOptions):
+ """
+ 设置id生成规则信息
+ """
+
+ if option.base_time < 100000:
+ raise ValueError("base time error.")
+
+ self.snowflake = snowflake_m1.SnowFlakeM1(option)
+
+ def next_id(self) -> int:
+ """
+ 获取新的UUID
+ """
+
+ if self.snowflake is None:
+ raise ValueError("please set id generator at first.")
+ return self.snowflake.next_id()
diff --git a/id_generator/idregister.py b/id_generator/idregister.py
new file mode 100644
index 0000000..787faac
--- /dev/null
+++ b/id_generator/idregister.py
@@ -0,0 +1,134 @@
+"""
+worker id generator
+"""
+
+# !/usr/bin/python
+# coding=UTF-8
+
+
+from threading import Thread
+import time
+import logging
+import redis
+
+
+class Register:
+ """
+ redis封装
+ - host 代表redis ip
+ - port 代表redis端口
+ - max_worker_id worker_id的最大值, 默认为100
+ - password redis的密码, 默认为空
+ """
+
+ def __init__(self, host, port, max_worker_id=100, password=None):
+ self.redis_impl = redis.StrictRedis(host=host, port=port, db=0, password=password)
+ self.loop_count = 0
+ self.max_loop_count = 10
+ self.worker_id_expire_time = 15
+ self.max_worker_id = max_worker_id
+ self.worker_id = -1
+ self.is_stop = False
+
+ def get_lock(self, key):
+ """
+ 获取分布式全局锁,并设置过期时间为30秒
+ """
+
+ if self.redis_impl.setnx(key, 1):
+ self.redis_impl.expire(key, 30)
+ return True
+ if self.redis_impl.ttl(key) < 0:
+ self.redis_impl.expire(key, 30)
+ return False
+
+ def stop(self):
+ """
+ 退出注册器的线程
+ """
+
+ self.is_stop = True
+
+ def get_worker_id(self):
+ """
+ 获取全局唯一worker_id, 会创建一个线程给worker id续期
+ 失败返回-1
+ """
+
+ self.loop_count = 0
+
+ def extern_life(my_id):
+ while 1:
+ time.sleep(self.worker_id_expire_time / 3)
+ # 是否关闭了
+ if self.is_stop:
+ return
+ # 更新生命周期
+ if self.worker_id != my_id:
+ break
+ try:
+ self.redis_impl.expire(
+ f"IdGen:WorkerId:Value:{my_id}",
+ self.worker_id_expire_time)
+ except Exception as exe:
+ logging.error(exe)
+ continue
+
+ self.worker_id = self.__get_next_worker_id()
+ if self.worker_id > -1:
+ Thread(target=extern_life, args=[self.worker_id]).start()
+ return self.worker_id
+
+ def __get_next_worker_id(self):
+ """
+ 获取全局唯一worker id内部实现
+ """
+
+ cur = self.redis_impl.incrby("IdGen:WorkerId:Index", 1)
+
+ def can_reset():
+ try:
+ reset_value = self.redis_impl.incr("IdGen:WorkerId:Value:Edit")
+ return reset_value != 1
+ except Exception as ept:
+ logging.error(ept)
+ return False
+
+ def end_reset():
+ try:
+ self.redis_impl.set("IdGen:WorkerId:Value:Edit", 0)
+ except Exception as ept:
+ logging.error(ept)
+
+ def is_available(worker_id: int):
+ try:
+ rst = self.redis_impl.get(f"IdGen:WorkerId:Value:{worker_id}")
+ return rst != "Y"
+ except Exception as ept:
+ logging.error(ept)
+ return False
+
+ if cur > self.max_worker_id:
+ if can_reset():
+ self.redis_impl.set("IdGen:WorkerId:Index", -1)
+ end_reset()
+ self.loop_count += 1
+
+ if self.loop_count > self.max_loop_count:
+ self.loop_count = 0
+ return -1
+
+ time.sleep(0.2 * self.loop_count)
+ return self.__get_next_worker_id()
+ time.sleep(0.2)
+ return self.__get_next_worker_id()
+ if is_available(cur):
+ self.redis_impl.setex(
+ f"IdGen:WorkerId:Value:{cur}",
+ self.worker_id_expire_time,
+ "Y"
+ )
+ self.loop_count = 0
+ return cur
+
+ return self.__get_next_worker_id()
diff --git a/id_generator/options.py b/id_generator/options.py
new file mode 100644
index 0000000..8558381
--- /dev/null
+++ b/id_generator/options.py
@@ -0,0 +1,43 @@
+"""
+生成器IdGenerator配置选项
+"""
+
+# !/usr/bin/python
+# coding=UTF-8
+
+
+class IdGeneratorOptions:
+ """
+ ID生成器配置
+ - worker_id 全局唯一id, 区分不同uuid生成器实例
+ - worker_id_bit_length 生成的uuid中worker_id占用的位数
+ - seq_bit_length 生成的uuid中序列号占用的位数
+ """
+
+ def __init__(self, worker_id=0, worker_id_bit_length=6, seq_bit_length=6):
+
+ # 雪花计算方法,(1-漂移算法|2-传统算法), 默认1。目前只实现了1。
+ self.method = 1
+
+ # 基础时间(ms单位), 不能超过当前系统时间
+ self.base_time = 1582136402000
+
+ # 机器码, 必须由外部设定, 最大值 2^worker_id_bit_length-1
+ self.worker_id = worker_id
+
+ # 机器码位长, 默认值6, 取值范围 [1, 15](要求:序列数位长+机器码位长不超过22)
+ self.worker_id_bit_length = worker_id_bit_length
+
+ # 序列数位长, 默认值6, 取值范围 [3, 21](要求:序列数位长+机器码位长不超过22)
+ self.seq_bit_length = seq_bit_length
+
+ # 最大序列数(含), 设置范围 [max_seq_number, 2^seq_bit_length-1]
+ # 默认值0, 表示最大序列数取最大值(2^seq_bit_length-1])
+ self.max_seq_number = 0
+
+ # 最小序列数(含), 默认值5, 取值范围 [5, max_seq_number], 每毫秒的前5个序列数对应编号0-4是保留位
+ # 其中1-4是时间回拨相应预留位, 0是手工新值预留位
+ self.min_seq_number = 5
+
+ # 最大漂移次数(含), 默认2000, 推荐范围500-10000(与计算能力有关)
+ self.top_over_cost_count = 2000
diff --git a/id_generator/snowflake.py b/id_generator/snowflake.py
new file mode 100644
index 0000000..ea36cfa
--- /dev/null
+++ b/id_generator/snowflake.py
@@ -0,0 +1,20 @@
+
+"""
+雪花算法生成器接口声明
+"""
+
+# !/usr/bin/python
+# coding=UTF-8
+
+
+class SnowFlake():
+
+ def __init__(self, options):
+ self.options = options
+
+ def next_id(self) -> int:
+ """
+ 获取新的UUID
+ """
+
+ return 0
diff --git a/id_generator/snowflake_m1.py b/id_generator/snowflake_m1.py
new file mode 100644
index 0000000..596b76d
--- /dev/null
+++ b/id_generator/snowflake_m1.py
@@ -0,0 +1,147 @@
+"""
+M1生成器
+"""
+
+# !/usr/bin/python
+# coding=UTF-8
+
+import threading
+import time
+from .snowflake import SnowFlake
+from .options import IdGeneratorOptions
+
+
+class SnowFlakeM1(SnowFlake):
+ """
+ M1规则ID生成器配置
+ """
+
+ def __init__(self, options: IdGeneratorOptions):
+ # 1.base_time
+ self.base_time = 1582136402000
+ if options.base_time != 0:
+ self.base_time = int(options.base_time)
+
+ # 2.worker_id_bit_length
+ self.worker_id_bit_length = 6
+ if options.worker_id_bit_length != 0:
+ self.worker_id_bit_length = int(options.worker_id_bit_length)
+
+ # 3.worker_id
+ self.worker_id = options.worker_id
+
+ # 4.seq_bit_length
+ self.seq_bit_length = 6
+ if options.seq_bit_length != 0:
+ self.seq_bit_length = int(options.seq_bit_length)
+
+ # 5.max_seq_number
+ self.max_seq_number = int(options.max_seq_number)
+ if options.max_seq_number <= 0:
+ self.max_seq_number = (1 << self.seq_bit_length) - 1
+
+ # 6.min_seq_number
+ self.min_seq_number = int(options.min_seq_number)
+
+ # 7.top_over_cost_count
+ self.top_over_cost_count = int(options.top_over_cost_count)
+
+ # 8.Others
+ self.__timestamp_shift = self.worker_id_bit_length + self.seq_bit_length
+ self.__current_seq_number = self.min_seq_number
+ self.__last_time_tick: int = 0
+ self.__turn_back_time_tick: int = 0
+ self.__turn_back_index: int = 0
+ self.__is_over_cost = False
+ self.___over_cost_count_in_one_term: int = 0
+ self.__id_lock = threading.Lock()
+
+ def __next_over_cost_id(self) -> int:
+ current_time_tick = self.__get_current_time_tick()
+ if current_time_tick > self.__last_time_tick:
+ self.__last_time_tick = current_time_tick
+ self.__current_seq_number = self.min_seq_number
+ self.__is_over_cost = False
+ self.___over_cost_count_in_one_term = 0
+ return self.__calc_id(self.__last_time_tick)
+
+ if self.___over_cost_count_in_one_term >= self.top_over_cost_count:
+ self.__last_time_tick = self.__get_next_time_tick()
+ self.__current_seq_number = self.min_seq_number
+ self.__is_over_cost = False
+ self.___over_cost_count_in_one_term = 0
+ return self.__calc_id(self.__last_time_tick)
+
+ if self.__current_seq_number > self.max_seq_number:
+ self.__last_time_tick += 1
+ self.__current_seq_number = self.min_seq_number
+ self.__is_over_cost = True
+ self.___over_cost_count_in_one_term += 1
+ return self.__calc_id(self.__last_time_tick)
+
+ return self.__calc_id(self.__last_time_tick)
+
+ def __next_normal_id(self) -> int:
+ current_time_tick = self.__get_current_time_tick()
+ if current_time_tick < self.__last_time_tick:
+ if self.__turn_back_time_tick < 1:
+ self.__turn_back_time_tick = self.__last_time_tick - 1
+ self.__turn_back_index += 1
+ # 每毫秒序列数的前5位是预留位, 0用于手工新值, 1-4是时间回拨次序
+ # 支持4次回拨次序(避免回拨重叠导致ID重复), 可无限次回拨(次序循环使用)。
+ if self.__turn_back_index > 4:
+ self.__turn_back_index = 1
+
+ return self.__calc_turn_back_id(self.__turn_back_time_tick)
+
+ # 时间追平时, _TurnBackTimeTick清零
+ self.__turn_back_time_tick = min(self.__turn_back_time_tick, 0)
+
+ if current_time_tick > self.__last_time_tick:
+ self.__last_time_tick = current_time_tick
+ self.__current_seq_number = self.min_seq_number
+ return self.__calc_id(self.__last_time_tick)
+
+ if self.__current_seq_number > self.max_seq_number:
+ self.__last_time_tick += 1
+ self.__current_seq_number = self.min_seq_number
+ self.__is_over_cost = True
+ self.___over_cost_count_in_one_term = 1
+ return self.__calc_id(self.__last_time_tick)
+
+ return self.__calc_id(self.__last_time_tick)
+
+ def __calc_id(self, use_time_tick) -> int:
+ self.__current_seq_number += 1
+ return (
+ (use_time_tick << self.__timestamp_shift) +
+ (self.worker_id << self.seq_bit_length) +
+ self.__current_seq_number
+ ) % int(1e64)
+
+ def __calc_turn_back_id(self, use_time_tick) -> int:
+ self.__turn_back_time_tick -= 1
+ return (
+ (use_time_tick << self.__timestamp_shift) +
+ (self.worker_id << self.seq_bit_length) +
+ self.__turn_back_index
+ ) % int(1e64)
+
+ def __get_current_time_tick(self) -> int:
+ return int((time.time_ns() / 1e6) - self.base_time)
+
+ def __get_next_time_tick(self) -> int:
+ temp_time_ticker = self.__get_current_time_tick()
+ while temp_time_ticker <= self.__last_time_tick:
+ # 0.001 = 1 mili sec
+ time.sleep(0.001)
+ temp_time_ticker = self.__get_current_time_tick()
+ return temp_time_ticker
+
+ def next_id(self) -> int:
+ with self.__id_lock:
+ if self.__is_over_cost:
+ nextid = self.__next_over_cost_id()
+ else:
+ nextid = self.__next_normal_id()
+ return nextid
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..52147c1
--- /dev/null
+++ b/main.py
@@ -0,0 +1,16 @@
+from fastapi import FastAPI
+from starlette.middleware.cors import CORSMiddleware
+
+from routers import routers
+
+app = FastAPI(title="Patch Service")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+for router in routers:
+ app.include_router(router)
diff --git a/models/base.py b/models/base.py
new file mode 100644
index 0000000..add1ab4
--- /dev/null
+++ b/models/base.py
@@ -0,0 +1,41 @@
+from sqlalchemy import Column, BigInteger, DateTime, event
+from sqlalchemy.ext.declarative import declared_attr
+
+from config.database import Base
+from datetime import datetime
+
+from utils.common import camel_to_snake
+from id_generator import options, generator
+
+# https://github.com/yitter/IdGenerator/tree/master/Python
+options = options.IdGeneratorOptions(worker_id=23)
+idgen = generator.DefaultIdGenerator()
+idgen.set_id_generator(options)
+
+
+# 第二层基类:包含ID
+class IdBase(Base):
+ __abstract__ = True
+
+ id = Column(BigInteger, primary_key=True, index=True)
+
+ @declared_attr
+ def __tablename__(cls):
+ # 自动把数据库实体类名驼峰转为数据库表名下划线
+ return camel_to_snake(cls.__name__)
+
+
+# 自动填充id
+@event.listens_for(IdBase, 'before_insert', propagate=True)
+def before_insert_listener(mapper, connection, target):
+ if target.id is None:
+ target.id = idgen.next_id()
+
+
+# 第二层基类:包含ID和审计字段
+class AuditBase(IdBase):
+ __abstract__ = True
+
+ create_time = Column(DateTime, nullable=True, default=datetime.now)
+ update_time = Column(DateTime, nullable=True, default=datetime.now, onupdate=datetime.now)
+
diff --git a/models/patch.py b/models/patch.py
new file mode 100644
index 0000000..e361157
--- /dev/null
+++ b/models/patch.py
@@ -0,0 +1,26 @@
+from sqlalchemy import Column, JSON, String, Text, BigInteger, Date
+
+from models.base import AuditBase
+
+
+class Patches(AuditBase):
+ name = Column(String(100), nullable=False)
+ location = Column(String(200))
+ status = Column(String(50))
+
+
+class Crops(AuditBase):
+ patch_id = Column(BigInteger, nullable=False)
+ name = Column(String(100), nullable=False)
+ variety = Column(String(100))
+ date = Column(Date)
+
+
+class Records(AuditBase):
+ crop_id = Column(BigInteger, nullable=False)
+ type = Column(String(50))
+ date = Column(Date)
+ content = Column(Text)
+ images = Column(JSON)
+ videos = Column(JSON)
+ stage = Column(String(50))
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..0738711
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,12 @@
+fastapi~=0.140.0
+python-dotenv~=1.2.2
+requests~=2.34.2
+starlette~=1.3.1
+pydantic~=2.13.4
+SQLAlchemy~=2.0.51
+asyncpg~=0.30.0
+uvicorn~=0.23.0
+pymysql~=1.2.0
+boto3~=1.40.59
+botocore~=1.40.59
+python-multipart~=0.0.20
\ No newline at end of file
diff --git a/routers/__init__.py b/routers/__init__.py
new file mode 100644
index 0000000..1dbbe25
--- /dev/null
+++ b/routers/__init__.py
@@ -0,0 +1,11 @@
+from .patch import router as patch_router
+from .crop import router as crop_router
+from .record import router as record_router
+from .file import router as file_router
+
+routers = [
+ patch_router,
+ crop_router,
+ record_router,
+ file_router,
+]
diff --git a/routers/crop.py b/routers/crop.py
new file mode 100644
index 0000000..3af55a9
--- /dev/null
+++ b/routers/crop.py
@@ -0,0 +1,29 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from config.database import get_db
+from schemas.patch import CropCreate, CropUpdate
+from service import crop_service
+
+router = APIRouter(prefix="/crops", tags=["crops"])
+
+
+@router.post("", response_model=bool)
+def create(data: CropCreate, db: Session = Depends(get_db)):
+ return crop_service.create_crop(db, data)
+
+
+@router.put("/{crop_id}", response_model=bool)
+def update(crop_id: int, data: CropUpdate, db: Session = Depends(get_db)):
+ crop = crop_service.update_crop(db, crop_id, data)
+ if not crop:
+ raise HTTPException(404, "Crop not found")
+ return crop
+
+
+@router.delete("/{crop_id}", response_model=bool)
+def delete(crop_id: int, db: Session = Depends(get_db)):
+ crop = crop_service.delete_crop(db, crop_id)
+ if not crop:
+ raise HTTPException(404, "Crop not found")
+ return crop
diff --git a/routers/file.py b/routers/file.py
new file mode 100644
index 0000000..fb4c6ce
--- /dev/null
+++ b/routers/file.py
@@ -0,0 +1,9 @@
+from fastapi import APIRouter, UploadFile, File
+from service import file_service
+
+router = APIRouter(prefix="/file", tags=["文件"])
+
+
+@router.post("/upload", summary="上传文件", response_model=str)
+async def upload(file: UploadFile = File(..., description="要上传的文件")):
+ return await file_service.upload_file(file)
diff --git a/routers/patch.py b/routers/patch.py
new file mode 100644
index 0000000..3ce15bf
--- /dev/null
+++ b/routers/patch.py
@@ -0,0 +1,43 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from config.database import get_db
+from schemas.patch import PatchResponse, PatchCreate, PatchUpdate
+from service import patch_service
+from service.patch_service import get_patch
+
+router = APIRouter(prefix="/patches", tags=["patches"])
+
+
+@router.post("", response_model=bool)
+def create(data: PatchCreate, db: Session = Depends(get_db)):
+ return patch_service.create_patch(db, data)
+
+
+@router.get("", response_model=list[PatchResponse])
+def list_all(db: Session = Depends(get_db)):
+ return patch_service.get_patches(db)
+
+
+@router.get("/{patch_id}", response_model=PatchResponse)
+def get_one(patch_id: int, db: Session = Depends(get_db)):
+ patch = get_patch(db, patch_id)
+ if not patch:
+ raise HTTPException(404, "Patch not found")
+ return patch
+
+
+@router.put("/{patch_id}", response_model=bool)
+def update(patch_id: int, data: PatchUpdate, db: Session = Depends(get_db)):
+ patch = patch_service.update_patch(db, patch_id, data)
+ if not patch:
+ raise HTTPException(404, "Patch not found")
+ return patch
+
+
+@router.delete("/{patch_id}", response_model=bool)
+def delete(patch_id: int, db: Session = Depends(get_db)):
+ patch = patch_service.delete_patch(db, patch_id)
+ if not patch:
+ raise HTTPException(404, "Patch not found")
+ return patch
diff --git a/routers/record.py b/routers/record.py
new file mode 100644
index 0000000..aceb22c
--- /dev/null
+++ b/routers/record.py
@@ -0,0 +1,42 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from config.database import get_db
+from schemas.patch import RecordResponse, RecordCreate, RecordUpdate
+from service import record_service
+
+router = APIRouter(prefix="/records", tags=["records"])
+
+
+@router.post("", response_model=bool)
+def create(data: RecordCreate, db: Session = Depends(get_db)):
+ return record_service.create_record(db, data)
+
+
+@router.get("/{record_id}", response_model=RecordResponse)
+def get_one(record_id: int, db: Session = Depends(get_db)):
+ record = record_service.get_record(db, record_id)
+ if not record:
+ raise HTTPException(404, "Record not found")
+ return record
+
+
+@router.get("/crop/{crop_id}", response_model=list[RecordResponse])
+def list_by_crop(crop_id: int, db: Session = Depends(get_db)):
+ return record_service.get_records_by_crop(db, crop_id)
+
+
+@router.put("/{record_id}", response_model=bool)
+def update(record_id: int, data: RecordUpdate, db: Session = Depends(get_db)):
+ record = record_service.update_record(db, record_id, data)
+ if not record:
+ raise HTTPException(404, "Record not found")
+ return record
+
+
+@router.delete("/{record_id}", response_model=bool)
+def delete(record_id: int, db: Session = Depends(get_db)):
+ record = record_service.delete_record(db, record_id)
+ if not record:
+ raise HTTPException(404, "Record not found")
+ return record
diff --git a/schemas/patch.py b/schemas/patch.py
new file mode 100644
index 0000000..4e68e37
--- /dev/null
+++ b/schemas/patch.py
@@ -0,0 +1,92 @@
+from pydantic import BaseModel
+from typing import Optional
+
+
+class PatchCreate(BaseModel):
+ name: str
+ location: Optional[str] = None
+ status: Optional[str] = ""
+
+
+class PatchUpdate(BaseModel):
+ name: Optional[str] = None
+ location: Optional[str] = None
+ status: Optional[str] = None
+
+
+class CropCreate(BaseModel):
+ patch_id: int
+ name: str
+ variety: Optional[str] = None
+ date: Optional[str] = None
+
+
+class CropUpdate(BaseModel):
+ name: Optional[str] = None
+ variety: Optional[str] = None
+ date: Optional[str] = None
+
+
+class CropResponse(CropCreate):
+ id: int
+
+ model_config = {
+ "from_attributes": True
+ }
+
+ @classmethod
+ def from_orm(cls, obj):
+ return cls(
+ id=obj.id,
+ patch_id=obj.patch_id,
+ name=obj.name,
+ variety=obj.variety,
+ date=obj.date.strftime("%Y-%m-%d") if obj.date else None,
+ )
+
+
+class PatchResponse(PatchCreate):
+ id: int
+ crop: Optional[CropResponse] = None
+
+ model_config = {"from_attributes": True}
+
+
+class RecordCreate(BaseModel):
+ crop_id: int
+ type: Optional[str] = None
+ date: Optional[str] = None
+ content: Optional[str] = None
+ images: Optional[list[str]] = None
+ videos: Optional[list[str]] = None
+ stage: Optional[str] = None
+
+
+class RecordUpdate(BaseModel):
+ type: Optional[str] = None
+ date: Optional[str] = None
+ content: Optional[str] = None
+ images: Optional[list[str]] = None
+ videos: Optional[list[str]] = None
+ stage: Optional[str] = None
+
+
+class RecordResponse(RecordCreate):
+ id: int
+
+ model_config = {
+ "from_attributes": True
+ }
+
+ @classmethod
+ def from_orm(cls, obj):
+ return cls(
+ id=obj.id,
+ crop_id=obj.crop_id,
+ type=obj.type,
+ date=obj.date.strftime("%Y-%m-%d") if obj.date else None,
+ content=obj.content,
+ images=obj.images,
+ videos=obj.videos,
+ stage=obj.stage,
+ )
diff --git a/service/crop_service.py b/service/crop_service.py
new file mode 100644
index 0000000..c732987
--- /dev/null
+++ b/service/crop_service.py
@@ -0,0 +1,42 @@
+from sqlalchemy import select, delete
+from sqlalchemy.orm import Session
+
+from models.patch import Crops, Records
+from schemas.patch import CropCreate, CropUpdate
+
+
+def create_crop(db: Session, data: CropCreate) -> bool:
+ crop = Crops(**data.model_dump())
+ db.add(crop)
+ db.commit()
+ db.refresh(crop)
+
+ return True
+
+
+def update_crop(db: Session, crop_id: int, data: CropUpdate) -> bool:
+ result = db.execute(select(Crops).where(Crops.id == crop_id))
+ crop = result.scalar_one_or_none()
+ if not crop:
+ return False
+
+ for k, v in data.model_dump(exclude_unset=True).items():
+ setattr(crop, k, v)
+ db.commit()
+ db.refresh(crop)
+
+ return True
+
+
+def delete_crop(db: Session, crop_id: int) -> bool:
+ result = db.execute(select(Crops).where(Crops.id == crop_id))
+ crop = result.scalar_one_or_none()
+ if not crop:
+ return False
+
+ db.execute(delete(Records).where(Records.crop_id == crop_id))
+
+ db.delete(crop)
+ db.commit()
+
+ return True
diff --git a/service/file_service.py b/service/file_service.py
new file mode 100644
index 0000000..191bccc
--- /dev/null
+++ b/service/file_service.py
@@ -0,0 +1,50 @@
+import uuid
+
+from fastapi import UploadFile, File, HTTPException
+
+from config.rustfs import s3
+
+ALLOWED_IMAGE_TYPES = [
+ # 图片
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+ "image/svg+xml",
+ # 视频
+ "video/mp4",
+ "video/mpeg",
+ "video/quicktime",
+ "video/x-msvideo",
+ "video/webm",
+ "video/x-matroska",
+ "video/ogg",
+]
+
+BUCKET = 'patch'
+NGINX_PROXY = 'rustfs'
+
+
+async def upload_file(file: UploadFile = File(...)) -> str:
+ # 校验文件类型
+ if file.content_type not in ALLOWED_IMAGE_TYPES:
+ raise HTTPException(
+ status_code=400,
+ detail="只允许上传图片或视频文件 (JPEG, PNG, GIF, WEBP, SVG, MP4, MOV, AVI, WEBM, MKV, OGV)"
+ )
+
+ file_ext = file.filename.split('.')[-1]
+ unique_filename = f"{uuid.uuid4().hex}.{file_ext}"
+
+ file_content = await file.read()
+
+ # 上传到S3
+ s3.put_object(
+ Bucket=BUCKET,
+ Key=unique_filename,
+ Body=file_content,
+ ContentType=file.content_type
+ )
+
+ # 返回文件url
+ return f"{NGINX_PROXY}/{BUCKET}/{unique_filename}"
\ No newline at end of file
diff --git a/service/patch_service.py b/service/patch_service.py
new file mode 100644
index 0000000..b81a752
--- /dev/null
+++ b/service/patch_service.py
@@ -0,0 +1,79 @@
+from sqlalchemy import select, delete
+from sqlalchemy.orm import Session, selectinload
+
+from models.patch import Patches, Crops, Records
+from schemas.patch import PatchCreate, PatchUpdate, PatchResponse, CropResponse
+
+
+def create_patch(db: Session, data: PatchCreate) -> bool:
+ patch = Patches(**data.model_dump())
+ db.add(patch)
+ db.commit()
+ db.refresh(patch)
+
+ return True
+
+
+def get_patches(db: Session) -> list[PatchResponse]:
+ patches = db.execute(select(Patches)).scalars().all()
+ result = []
+ for p in patches:
+ crop = db.execute(
+ select(Crops).where(Crops.patch_id == p.id)
+ ).scalar_one_or_none()
+ data = PatchResponse.model_validate(p)
+ data.crop = CropResponse.from_orm(crop) if crop else None
+ result.append(data)
+
+ return result
+
+
+def get_patch(db: Session, patch_id: int) -> PatchResponse | None:
+ patch = db.execute(
+ select(Patches).where(Patches.id == patch_id)
+ ).scalar_one_or_none()
+ if not patch:
+ return None
+ crop = db.execute(
+ select(Crops).where(Crops.patch_id == patch_id)
+ ).scalar_one_or_none()
+ data = PatchResponse.model_validate(patch)
+ data.crop = CropResponse.from_orm(crop) if crop else None
+
+ return data
+
+
+def update_patch(db: Session, patch_id: int, data: PatchUpdate) -> bool:
+ result = db.execute(select(Patches).where(Patches.id == patch_id))
+ patch = result.scalar_one_or_none()
+ if not patch:
+ return False
+
+ for k, v in data.model_dump(exclude_unset=True).items():
+ setattr(patch, k, v)
+ db.commit()
+ db.refresh(patch)
+
+ return True
+
+
+def delete_patch(db: Session, patch_id: int) -> bool:
+ result = db.execute(select(Patches).where(Patches.id == patch_id))
+ patch = result.scalar_one_or_none()
+ if not patch:
+ return False
+
+ # 先查作物
+ crop_result = db.execute(select(Crops).where(Crops.patch_id == patch_id))
+ crop = crop_result.scalar_one_or_none()
+
+ # 有作物就先删记录,再删作物
+ if crop:
+ db.execute(delete(Records).where(Records.crop_id == crop.id))
+ db.delete(crop)
+
+ # 最后删菜地
+ db.delete(patch)
+ db.commit()
+
+ return True
diff --git a/service/record_service.py b/service/record_service.py
new file mode 100644
index 0000000..2e3cf1f
--- /dev/null
+++ b/service/record_service.py
@@ -0,0 +1,55 @@
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from models.patch import Records
+from schemas.patch import RecordCreate, RecordResponse, RecordUpdate
+
+
+def create_record(db: Session, data: RecordCreate) -> bool:
+ record = Records(**data.model_dump())
+ db.add(record)
+ db.commit()
+ db.refresh(record)
+
+ return True
+
+
+def get_record(db: Session, record_id: int) -> RecordResponse | None:
+ result = db.execute(select(Records).where(Records.id == record_id))
+ record = result.scalar_one_or_none()
+
+ return RecordResponse.from_orm(record) if record else None
+
+
+def get_records_by_crop(db: Session, crop_id: int) -> list[RecordResponse]:
+ result = db.execute(
+ select(Records)
+ .where(Records.crop_id == crop_id)
+ .order_by(Records.date.desc())
+ )
+ return [RecordResponse.from_orm(r) for r in result.scalars().all()]
+
+
+def update_record(db: Session, record_id: int, data: RecordUpdate) -> bool:
+ result = db.execute(select(Records).where(Records.id == record_id))
+ record = result.scalar_one_or_none()
+ if not record:
+ return False
+
+ for k, v in data.model_dump(exclude_unset=True).items():
+ setattr(record, k, v)
+ db.commit()
+ db.refresh(record)
+
+ return True
+
+
+def delete_record(db: Session, record_id: int) -> bool:
+ result = db.execute(select(Records).where(Records.id == record_id))
+ record = result.scalar_one_or_none()
+ if record:
+ db.delete(record)
+ db.commit()
+ return True
+
+ return False
diff --git a/utils/common.py b/utils/common.py
new file mode 100644
index 0000000..bb599fe
--- /dev/null
+++ b/utils/common.py
@@ -0,0 +1,13 @@
+import re
+
+
+def camel_to_snake(name: str) -> str:
+ """将驼峰命名转换为蛇形命名(CamelCase → snake_case)"""
+ name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
+ return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
+
+
+def snake_to_camel(name: str) -> str:
+ """将蛇形命名转换为驼峰命名(snake_case → CamelCase)"""
+ components = name.split('_')
+ return ''.join(x.title() for x in components)