diff --git a/.env.example b/.env.example index 1c46674..24736ca 100644 --- a/.env.example +++ b/.env.example @@ -29,4 +29,6 @@ PAY_MCH_ID= PAY_API_KEY= PAY_NOTIFY_URL= -USER_MAX_CREATED_CARD_NUM=18 \ No newline at end of file +# 会员配置 +USER_MAX_CREATED_CARD_NUM=18 +UPLOAD_REWARD_POINTS_GIFT_ID=-1 \ No newline at end of file diff --git a/alembic/versions/4a97e1258bbb_feat_points.py b/alembic/versions/4a97e1258bbb_feat_points.py new file mode 100644 index 0000000..d120d2f --- /dev/null +++ b/alembic/versions/4a97e1258bbb_feat_points.py @@ -0,0 +1,69 @@ +"""feat points + +Revision ID: 4a97e1258bbb +Revises: e2a204afe670 +Create Date: 2025-05-07 20:08:47.923509 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '4a97e1258bbb' +down_revision: Union[str, None] = 'e2a204afe670' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('system_points_transaction', + sa.Column('user_id', sa.Integer(), nullable=False, comment='用户id'), + sa.Column('transaction_type', sa.Integer(), nullable=False, comment='交易类型:1-充值 2-消费 3-赠送 4-过期 5-系统调整'), + sa.Column('product_id', sa.Integer(), nullable=True, comment='关联产品id'), + sa.Column('points', sa.Integer(), nullable=False, comment='积分数量(正数表示增加,负数表示减少)'), + sa.Column('balance', sa.Integer(), nullable=False, comment='交易后余额'), + sa.Column('status', sa.Integer(), nullable=False, comment='状态:0-无效 1-有效 2-已撤销'), + sa.Column('description', sa.String(length=255), nullable=True, comment='交易描述'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='自增id'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), + sa.PrimaryKeyConstraint('id'), + comment='积分消费记录表' + ) + op.add_column('system_orders', sa.Column('ip_address', sa.String(length=255), nullable=True, comment='操作ip')) + op.add_column('system_products', sa.Column('gift_id', sa.Integer(), nullable=True, comment='礼物产品id')) + op.alter_column('system_products', 'category', + existing_type=mysql.ENUM('VIP', 'POINTS', 'GIFT'), + comment='商品分类, vip-会员服务 points-积分服务 gift-礼物', + existing_comment='商品分类, vip-会员服务 points-积分服务', + existing_nullable=False) + op.alter_column('system_products', 'action', + existing_type=mysql.VARCHAR(length=30), + type_=sa.Enum('VIP_RENEWAL', 'RECHARGE_POINTS', 'FREE_GIFT', name='productaction', inherit_schema=True), + comment='付款后的动作,会员续期:vip_renewal 积分:recharge_points 赠送产品:free_gift', + existing_comment='付款后的动作,会员续期:vip_renewal 积分:recharge_points', + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('system_products', 'action', + existing_type=sa.Enum('VIP_RENEWAL', 'RECHARGE_POINTS', 'FREE_GIFT', name='productaction', inherit_schema=True), + type_=mysql.VARCHAR(length=30), + comment='付款后的动作,会员续期:vip_renewal 积分:recharge_points', + existing_comment='付款后的动作,会员续期:vip_renewal 积分:recharge_points 赠送产品:free_gift', + existing_nullable=False) + op.alter_column('system_products', 'category', + existing_type=mysql.ENUM('VIP', 'POINTS', 'GIFT'), + comment='商品分类, vip-会员服务 points-积分服务', + existing_comment='商品分类, vip-会员服务 points-积分服务 gift-礼物', + existing_nullable=False) + op.drop_column('system_products', 'gift_id') + op.drop_column('system_orders', 'ip_address') + op.drop_table('system_points_transaction') + # ### end Alembic commands ### diff --git a/config.py b/config.py index cb253b6..1b56e72 100644 --- a/config.py +++ b/config.py @@ -68,6 +68,7 @@ class Settings(BaseSettings): # 会员配置 USER_MAX_CREATED_CARD_NUM: int = 18 + UPLOAD_REWARD_POINTS_GIFT_ID: int = -1 model_config = SettingsConfigDict(env_file=f'.env.{ENVIRONMENT}', env_file_encoding='utf-8') diff --git a/core/base/schema.py b/core/base/schema.py index 4f9747e..ccae32a 100644 --- a/core/base/schema.py +++ b/core/base/schema.py @@ -61,11 +61,13 @@ class CardCategoryType(enum.Enum): class ProductCategory(enum.Enum): VIP = 'vip' POINTS = 'points' + GIFT = 'gift' class ProductAction(enum.Enum): VIP_RENEWAL = 'vip_renewal' RECHARGE_POINTS = 'recharge_points' + FREE_GIFT = 'free_gift' class OrderStatusType(enum.Enum): diff --git a/core/db/models.py b/core/db/models.py index 0ae6ee4..f7d24f3 100644 --- a/core/db/models.py +++ b/core/db/models.py @@ -148,11 +148,11 @@ class Card(BaseModel, DateModel): create_user_id: Mapped[int] = mapped_column(Integer, nullable=True, comment='创建者') perfect_user_id: Mapped[int] = mapped_column(Integer, nullable=True, comment='完善者') - category: Mapped[str] = mapped_column( + category: Mapped[base_schema.CardCategoryType] = mapped_column( Enum(base_schema.CardCategoryType, inherit_schema=True), nullable=True, server_default=base_schema.CardCategoryType.OTHER.value, comment='卡片分类' ) - source: Mapped[str] = mapped_column( + source: Mapped[base_schema.PlatformSourceType] = mapped_column( Enum(base_schema.PlatformSourceType, inherit_schema=True), comment='来源,PSA、BGS、CGC、GBTC、CCG、BCTC、CIC :采集 、OTHER:自定义' ) @@ -205,7 +205,7 @@ class SearchCardCache(BaseModel, DateModel): cert_number: Mapped[str] = mapped_column(String(50), nullable=False, comment='评级卡号') data: Mapped[dict] = mapped_column(JSON, comment='标准数据') raw_data: Mapped[dict] = mapped_column(JSON, comment='原始数据') - source: Mapped[str] = mapped_column( + source: Mapped[base_schema.SupportPlatformType] = mapped_column( Enum(base_schema.SupportPlatformType, inherit_schema=True), comment='数据来源:PSA、BGS、CGC、GBTC、CCG、BCTC、CIC' ) @@ -216,12 +216,17 @@ class Product(BaseModel, DateModel): __table_args__ = {'comment': '产品信息表'} name: Mapped[str] = mapped_column(String(20), comment='产品名') - category: Mapped[str] = mapped_column( - Enum(base_schema.ProductCategory, inherit_schema=True), comment='商品分类, vip-会员服务 points-积分服务' + category: Mapped[base_schema.ProductCategory] = mapped_column( + Enum(base_schema.ProductCategory, inherit_schema=True), + comment='商品分类, vip-会员服务 points-积分服务 gift-礼物' ) price: Mapped[int] = mapped_column(Integer, comment='价格,不带小数点 单位为分') - action: Mapped[str] = mapped_column(String(30), comment='付款后的动作,会员续期:vip_renewal 积分:recharge_points') + action: Mapped[base_schema.ProductAction] = mapped_column( + Enum(base_schema.ProductAction, inherit_schema=True), + comment='付款后的动作,会员续期:vip_renewal 积分:recharge_points 赠送产品:free_gift' + ) number: Mapped[int] = mapped_column(Integer, comment='执行动作的增量值') + gift_id: Mapped[int] = mapped_column(Integer, nullable=True, comment='礼物产品id') description: Mapped[str] = mapped_column(Text, nullable=True, comment='商品描述') @@ -244,6 +249,7 @@ class Order(BaseModel, DateModel): server_default=base_schema.OrderStatusType.INIT.value, comment='订单状态,init:初始化 fail:失败 ok:完成 wait payment:待支付 cancel. 取消' ) + ip_address: Mapped[str] = mapped_column(String(255), nullable=True, comment='操作ip') class Chat(BaseModel, DateModel): @@ -305,6 +311,22 @@ class ReadStatus(BaseModel, DateModel): return f'User: {self.user_id}, Message: {self.last_read_message_id}' +class PointsTransaction(BaseModel, DateModel): + __tablename__ = 'system_points_transaction' + __table_args__ = ( + {'comment': '积分消费记录表'} + ) + + user_id: Mapped[int] = mapped_column(Integer, comment='用户id') + transaction_type: Mapped[int] = mapped_column(Integer, comment='交易类型:1-充值 2-消费 3-赠送 4-过期 5-系统调整') + product_id: Mapped[int] = mapped_column(Integer, nullable=True, comment='关联产品id') + points : Mapped[int] = mapped_column(Integer, comment='积分数量(正数表示增加,负数表示减少)') + balance : Mapped[int] = mapped_column(Integer, comment='交易后余额') + status : Mapped[int] = mapped_column(Integer, default=1, comment='状态:0-无效 1-有效 2-已撤销') + description: Mapped[str] = mapped_column(String(255), nullable=True, comment='交易描述') + + + chat_participant = Table( 'chat_participant', BaseModel.metadata, diff --git a/core/routers/card/actions.py b/core/routers/card/actions.py new file mode 100644 index 0000000..0dce19d --- /dev/null +++ b/core/routers/card/actions.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# @Project : X25020501_card_book +# @File : actions.py.py +# @Date : 2025/5/7 20:38 +# @Software : PyCharm +# @Author : xingc +# @Desc : +import logging + +from core.base.schema import ProductCategory +from core.db.crud import CRUD +from core.db.models import User, Product +from core.routers.auth.schema import LoginUser +from core.routers.pay.actions import actions_handler + +logger = logging.getLogger(__name__) + + +async def reward_points_after_uploading_pictures( + db_crud: CRUD, + gift_product_id: int, + login_user: LoginUser +): + product: Product = await db_crud.get( + model=Product, filters={ + 'id': gift_product_id, + 'category': ProductCategory.GIFT + } + ) + if not product: + logger.warning(f'Product not found -> {gift_product_id}') + return + + user: User = await db_crud.get(model=User, filters={'user_id': login_user.user_id}) + + try: + await actions_handler(db_crud, product, user) + except Exception as e: + logger.error(f'Reward Points After Uploading Pictures -> {e}') diff --git a/core/routers/card/routes.py b/core/routers/card/routes.py index 276d1b6..486d429 100644 --- a/core/routers/card/routes.py +++ b/core/routers/card/routes.py @@ -23,6 +23,7 @@ from core.routers.auth.services import get_current_user_form_http from core.utils.helper import generate_keyname, check_picture_for_empty from . import schema +from .actions import reward_points_after_uploading_pictures from .scraper import card_scraper from .services import check_user_card_created_permission @@ -190,17 +191,18 @@ async def created_card( login_user: LoginUser = Depends(get_current_user_form_http), db_session: AsyncSession = Depends(get_async_session) ): - card_crud = CRUD(db_session, Card) + db_crud = CRUD(db_session, Card) # 确认用户存卡权限 if await check_user_card_created_permission( db_session, login_user=login_user ) is False: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=f'非会员仅支持存入{settings.USER_MAX_CREATED_CARD_NUM}张卡片' + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'非会员仅支持存入{settings.USER_MAX_CREATED_CARD_NUM}张卡片' ) # 卡片库查询 - card: Card = await card_crud.get(filters={'cert_number': payload.cert_number}) + card: Card = await db_crud.get(filters={'cert_number': payload.cert_number}) if payload.source.value != 'other': if card is None: # 卡片缓存库转正至卡片库, 重新查询保障数据准确性 @@ -208,7 +210,7 @@ async def created_card( cache_card = await cache_card_crud.get( filters={'cert_number': payload.cert_number, 'source': payload.source.value} ) - card = await card_crud.create( + card = await db_crud.create( data={ **cache_card.data, 'category': payload.category, @@ -232,17 +234,23 @@ async def created_card( if card_picture.get(key) is None: card_picture[key] = value - await card_crud.update( + await db_crud.update( data={ 'card_picture': card_picture, 'perfect_user_id': login_user.user_id, }, filters={'id': card.id} ) + # 上传图片奖励 + if payload.reward and settings.UPLOAD_REWARD_POINTS_GIFT_ID > 0: + await reward_points_after_uploading_pictures( + db_crud, gift_product_id=settings.UPLOAD_REWARD_POINTS_GIFT_ID, login_user=login_user + ) + else: if card is None: - card: Card = await card_crud.create( + card: Card = await db_crud.create( data={ **payload.dict(exclude_none=True, exclude={'reward'}), 'create_user_id': login_user.user_id, @@ -251,12 +259,12 @@ async def created_card( filters={'cert_number': payload.cert_number, 'source': payload.source.value} ) - collection_crud = CRUD(db_session, UserCollection) record = { 'user_id': login_user.user_id, 'card_id': card.id } - result = await collection_crud.create( + await db_crud.create( + model=UserCollection, data=record, filters=record ) @@ -299,7 +307,8 @@ async def display_card_status( db_session, login_user=login_user, other_conditions={'displayed': True} ) is False and action == 'displayed': raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=f'非会员仅支持展示{settings.USER_MAX_CREATED_CARD_NUM}张卡片' + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'非会员仅支持展示{settings.USER_MAX_CREATED_CARD_NUM}张卡片' ) db_crud = CRUD(db_session, UserCollection) diff --git a/core/routers/pay/actions.py b/core/routers/pay/actions.py index 3e663fd..5d8424f 100644 --- a/core/routers/pay/actions.py +++ b/core/routers/pay/actions.py @@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.base.schema import ProductCategory from core.db.crud import CRUD from core.db.engine import get_async_session -from core.db.models import User, Order, Product +from core.db.models import User, Order, Product, PointsTransaction logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ async def actions_after_payment( logger.error(f'Order not found -> {order_id}') return - product: Product = await db_crud.get(model=Product, filters={'product_id': order.product_id}) + product: Product = await db_crud.get(model=Product, filters={'id': order.product_id}) if not product: logger.warning(f'Product not found -> {order.product_id}') return @@ -40,30 +40,62 @@ async def actions_after_payment( logger.warning(f'User not found -> {order.order_id}') return - if product.action == ProductCategory.VIP: - vip_expire_date = ( - user.vip_expire_date or datetime.now() - ) + timedelta(days=product.number) - await db_crud.update( - model=User, - data={ - 'vip_expire_date': vip_expire_date.date() - }, - filters={'user_id': order.user_id} + await actions_handler(db_crud, product, user) + if product.gift_id: + gift_product = await db_crud.get( + model=Product, filters={ + 'id': product.gift_id, + 'category': ProductCategory.GIFT + } ) - elif product.action == ProductCategory.POINTS: - points = ( - user.points or 0 - ) + product.number - await db_crud.update( - model=User, - data={ - 'points': points - }, - filters={'user_id': order.user_id} - ) - else: - logger.error(f'Unknown action -> {product.action}') + await actions_handler(db_crud, gift_product, user) + except Exception as e: logger.error(f'Payment Actions Exception occurred -> {e}') - logger.exception(e) \ No newline at end of file + logger.exception(e) + + +async def actions_handler(db_crud: CRUD, product: Product, user: User): + if product.action == ProductCategory.VIP: + vip_expire_date = ( + user.vip_expire_date or datetime.now() + ) + timedelta(days=product.number) + await db_crud.update( + model=User, + data={ + 'vip_expire_date': vip_expire_date.date() + }, + filters={'user_id': user.user_id} + ) + elif product.action == ProductCategory.POINTS: + points = ( + user.points or 0 + ) + product.number + await db_crud.update( + model=User, + data={ + 'points': points + }, + filters={'user_id': user.user_id} + ) + + if product.category.value == 'gift': + transaction_type = 3 + else: + transaction_type = 1 + + await db_crud.update( + model=PointsTransaction, + data={ + 'user_id': user.user_id, + 'transaction_type': transaction_type, + 'points': product.number, + 'product_id': product.id, + 'balance': points, + 'description': f'{product.name} - {product.number}' + }, + filters={'id': -1} + ) + + else: + logger.error(f'Unknown action -> {product.action}') diff --git a/core/routers/pay/routes.py b/core/routers/pay/routes.py index 4ae979f..89d865b 100644 --- a/core/routers/pay/routes.py +++ b/core/routers/pay/routes.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.base import schema as base_schema from core.db.crud import CRUD from core.db.engine import get_async_session -from core.db.models import Order +from core.db.models import Order, Product from core.routers.auth.schema import LoginUser from core.routers.auth.services import get_current_user_form_http from core.utils.helper import generate_keyname, generate_order_number @@ -55,6 +55,14 @@ async def create_order( login_user: LoginUser = Depends(get_current_user_form_http), db_session: AsyncSession = Depends(get_async_session), ): + db_crud = CRUD(db_session, Order) + product = await db_crud.get(model=Product, filters={'id': payload.product_id}) + if product is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='产品不存在') + + if product.number != payload.total_fee: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='产品费用不合法') + client_ip = ( request.headers.get('X-Real-IP') or request.headers.get('x-real-ip') or @@ -67,7 +75,7 @@ async def create_order( create_ip=client_ip ) payment_params = wechat_pay_api.generate_payment_params(prepay_id) - db_crud = CRUD(db_session, Order) + order: Order = await db_crud.create( data={ 'order_id': out_trade_no, @@ -112,7 +120,7 @@ async def wechat_notify( # 处理支付结果 db_crud = CRUD(db_session, Order) record = { - 'out_trade_no': params['out_trade_no'], + 'order_id': params['out_trade_no'], 'transaction_id': params['transaction_id'] } if params['return_code'] == 'SUCCESS' and params['result_code'] == 'SUCCESS': diff --git a/core/routers/pay/schema.py b/core/routers/pay/schema.py index fd7aafb..2a785f9 100644 --- a/core/routers/pay/schema.py +++ b/core/routers/pay/schema.py @@ -21,7 +21,7 @@ class PaymentParamsOut(BaseModel): timeStamp: str nonceStr: str package: str - package: str + paySign: str signType: str diff --git a/core/routers/product/routes.py b/core/routers/product/routes.py index ba5666c..0296c58 100644 --- a/core/routers/product/routes.py +++ b/core/routers/product/routes.py @@ -9,6 +9,7 @@ import logging from typing import List from fastapi import APIRouter, Depends +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.base import schema as base_schema @@ -38,6 +39,24 @@ async def get_products( ): db_crud = CRUD(db_session, Product) products = await db_crud.get_all(filters={'category': category}) + gift_product_ids = set() + for product in products: + if product.gift_id: + gift_product_ids.add(product.gift_id) + + gift_products = { + p.id: p + for p in + await db_crud.get_all( + filters={ + 'category': base_schema.ProductCategory.GIFT, + 'id__in': gift_product_ids + } + ) + } + for product in products: + product.gift_product = gift_products.get(product.gift_id) + return { 'data': products } diff --git a/core/routers/product/schema.py b/core/routers/product/schema.py index 85858e7..e3c7b30 100644 --- a/core/routers/product/schema.py +++ b/core/routers/product/schema.py @@ -10,10 +10,14 @@ from pydantic import BaseModel from core.base.schema import ProductCategory -class ProductOut(BaseModel): +class Product(BaseModel): id: int name: str category: ProductCategory price: int number: int description: str | None + + +class ProductOut(Product): + gift_product: Product | None