feat(points): 新增积分赠送

This commit is contained in:
xingc
2025-05-07 22:52:09 +08:00
parent 9ce1ebbb98
commit 1fa4a7fc36
12 changed files with 254 additions and 47 deletions

View File

@@ -29,4 +29,6 @@ PAY_MCH_ID=
PAY_API_KEY= PAY_API_KEY=
PAY_NOTIFY_URL= PAY_NOTIFY_URL=
USER_MAX_CREATED_CARD_NUM=18 # 会员配置
USER_MAX_CREATED_CARD_NUM=18
UPLOAD_REWARD_POINTS_GIFT_ID=-1

View File

@@ -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 ###

View File

@@ -68,6 +68,7 @@ class Settings(BaseSettings):
# 会员配置 # 会员配置
USER_MAX_CREATED_CARD_NUM: int = 18 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') model_config = SettingsConfigDict(env_file=f'.env.{ENVIRONMENT}', env_file_encoding='utf-8')

View File

@@ -61,11 +61,13 @@ class CardCategoryType(enum.Enum):
class ProductCategory(enum.Enum): class ProductCategory(enum.Enum):
VIP = 'vip' VIP = 'vip'
POINTS = 'points' POINTS = 'points'
GIFT = 'gift'
class ProductAction(enum.Enum): class ProductAction(enum.Enum):
VIP_RENEWAL = 'vip_renewal' VIP_RENEWAL = 'vip_renewal'
RECHARGE_POINTS = 'recharge_points' RECHARGE_POINTS = 'recharge_points'
FREE_GIFT = 'free_gift'
class OrderStatusType(enum.Enum): class OrderStatusType(enum.Enum):

View File

@@ -148,11 +148,11 @@ class Card(BaseModel, DateModel):
create_user_id: Mapped[int] = mapped_column(Integer, nullable=True, comment='创建者') create_user_id: Mapped[int] = mapped_column(Integer, nullable=True, comment='创建者')
perfect_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), Enum(base_schema.CardCategoryType, inherit_schema=True),
nullable=True, server_default=base_schema.CardCategoryType.OTHER.value, comment='卡片分类' 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), Enum(base_schema.PlatformSourceType, inherit_schema=True),
comment='来源PSA、BGS、CGC、GBTC、CCG、BCTC、CIC :采集 、OTHER自定义' 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='评级卡号') cert_number: Mapped[str] = mapped_column(String(50), nullable=False, comment='评级卡号')
data: Mapped[dict] = mapped_column(JSON, comment='标准数据') data: Mapped[dict] = mapped_column(JSON, comment='标准数据')
raw_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), Enum(base_schema.SupportPlatformType, inherit_schema=True),
comment='数据来源PSA、BGS、CGC、GBTC、CCG、BCTC、CIC' comment='数据来源PSA、BGS、CGC、GBTC、CCG、BCTC、CIC'
) )
@@ -216,12 +216,17 @@ class Product(BaseModel, DateModel):
__table_args__ = {'comment': '产品信息表'} __table_args__ = {'comment': '产品信息表'}
name: Mapped[str] = mapped_column(String(20), comment='产品名') name: Mapped[str] = mapped_column(String(20), comment='产品名')
category: Mapped[str] = mapped_column( category: Mapped[base_schema.ProductCategory] = mapped_column(
Enum(base_schema.ProductCategory, inherit_schema=True), comment='商品分类, vip-会员服务 points-积分服务' Enum(base_schema.ProductCategory, inherit_schema=True),
comment='商品分类, vip-会员服务 points-积分服务 gift-礼物'
) )
price: Mapped[int] = mapped_column(Integer, comment='价格,不带小数点 单位为分') 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='执行动作的增量值') 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='商品描述') description: Mapped[str] = mapped_column(Text, nullable=True, comment='商品描述')
@@ -244,6 +249,7 @@ class Order(BaseModel, DateModel):
server_default=base_schema.OrderStatusType.INIT.value, server_default=base_schema.OrderStatusType.INIT.value,
comment='订单状态init初始化 fail失败 ok完成 wait payment待支付 cancel. 取消' comment='订单状态init初始化 fail失败 ok完成 wait payment待支付 cancel. 取消'
) )
ip_address: Mapped[str] = mapped_column(String(255), nullable=True, comment='操作ip')
class Chat(BaseModel, DateModel): class Chat(BaseModel, DateModel):
@@ -305,6 +311,22 @@ class ReadStatus(BaseModel, DateModel):
return f'User: {self.user_id}, Message: {self.last_read_message_id}' 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 = Table(
'chat_participant', 'chat_participant',
BaseModel.metadata, BaseModel.metadata,

View File

@@ -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}')

View File

@@ -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 core.utils.helper import generate_keyname, check_picture_for_empty
from . import schema from . import schema
from .actions import reward_points_after_uploading_pictures
from .scraper import card_scraper from .scraper import card_scraper
from .services import check_user_card_created_permission 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), login_user: LoginUser = Depends(get_current_user_form_http),
db_session: AsyncSession = Depends(get_async_session) 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( if await check_user_card_created_permission(
db_session, login_user=login_user db_session, login_user=login_user
) is False: ) is False:
raise HTTPException( 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 payload.source.value != 'other':
if card is None: if card is None:
# 卡片缓存库转正至卡片库, 重新查询保障数据准确性 # 卡片缓存库转正至卡片库, 重新查询保障数据准确性
@@ -208,7 +210,7 @@ async def created_card(
cache_card = await cache_card_crud.get( cache_card = await cache_card_crud.get(
filters={'cert_number': payload.cert_number, 'source': payload.source.value} filters={'cert_number': payload.cert_number, 'source': payload.source.value}
) )
card = await card_crud.create( card = await db_crud.create(
data={ data={
**cache_card.data, **cache_card.data,
'category': payload.category, 'category': payload.category,
@@ -232,17 +234,23 @@ async def created_card(
if card_picture.get(key) is None: if card_picture.get(key) is None:
card_picture[key] = value card_picture[key] = value
await card_crud.update( await db_crud.update(
data={ data={
'card_picture': card_picture, 'card_picture': card_picture,
'perfect_user_id': login_user.user_id, 'perfect_user_id': login_user.user_id,
}, },
filters={'id': card.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: else:
if card is None: if card is None:
card: Card = await card_crud.create( card: Card = await db_crud.create(
data={ data={
**payload.dict(exclude_none=True, exclude={'reward'}), **payload.dict(exclude_none=True, exclude={'reward'}),
'create_user_id': login_user.user_id, '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} filters={'cert_number': payload.cert_number, 'source': payload.source.value}
) )
collection_crud = CRUD(db_session, UserCollection)
record = { record = {
'user_id': login_user.user_id, 'user_id': login_user.user_id,
'card_id': card.id 'card_id': card.id
} }
result = await collection_crud.create( await db_crud.create(
model=UserCollection,
data=record, data=record,
filters=record filters=record
) )
@@ -299,7 +307,8 @@ async def display_card_status(
db_session, login_user=login_user, other_conditions={'displayed': True} db_session, login_user=login_user, other_conditions={'displayed': True}
) is False and action == 'displayed': ) is False and action == 'displayed':
raise HTTPException( 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) db_crud = CRUD(db_session, UserCollection)

View File

@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from core.base.schema import ProductCategory from core.base.schema import ProductCategory
from core.db.crud import CRUD from core.db.crud import CRUD
from core.db.engine import get_async_session 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__) logger = logging.getLogger(__name__)
@@ -30,7 +30,7 @@ async def actions_after_payment(
logger.error(f'Order not found -> {order_id}') logger.error(f'Order not found -> {order_id}')
return 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: if not product:
logger.warning(f'Product not found -> {order.product_id}') logger.warning(f'Product not found -> {order.product_id}')
return return
@@ -40,30 +40,62 @@ async def actions_after_payment(
logger.warning(f'User not found -> {order.order_id}') logger.warning(f'User not found -> {order.order_id}')
return return
if product.action == ProductCategory.VIP: await actions_handler(db_crud, product, user)
vip_expire_date = ( if product.gift_id:
user.vip_expire_date or datetime.now() gift_product = await db_crud.get(
) + timedelta(days=product.number) model=Product, filters={
await db_crud.update( 'id': product.gift_id,
model=User, 'category': ProductCategory.GIFT
data={ }
'vip_expire_date': vip_expire_date.date()
},
filters={'user_id': order.user_id}
) )
elif product.action == ProductCategory.POINTS: await actions_handler(db_crud, gift_product, user)
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}')
except Exception as e: except Exception as e:
logger.error(f'Payment Actions Exception occurred -> {e}') logger.error(f'Payment Actions Exception occurred -> {e}')
logger.exception(e) 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}')

View File

@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from core.base import schema as base_schema from core.base import schema as base_schema
from core.db.crud import CRUD from core.db.crud import CRUD
from core.db.engine import get_async_session 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.schema import LoginUser
from core.routers.auth.services import get_current_user_form_http from core.routers.auth.services import get_current_user_form_http
from core.utils.helper import generate_keyname, generate_order_number 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), login_user: LoginUser = Depends(get_current_user_form_http),
db_session: AsyncSession = Depends(get_async_session), 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 = ( client_ip = (
request.headers.get('X-Real-IP') or request.headers.get('X-Real-IP') or
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 create_ip=client_ip
) )
payment_params = wechat_pay_api.generate_payment_params(prepay_id) payment_params = wechat_pay_api.generate_payment_params(prepay_id)
db_crud = CRUD(db_session, Order)
order: Order = await db_crud.create( order: Order = await db_crud.create(
data={ data={
'order_id': out_trade_no, 'order_id': out_trade_no,
@@ -112,7 +120,7 @@ async def wechat_notify(
# 处理支付结果 # 处理支付结果
db_crud = CRUD(db_session, Order) db_crud = CRUD(db_session, Order)
record = { record = {
'out_trade_no': params['out_trade_no'], 'order_id': params['out_trade_no'],
'transaction_id': params['transaction_id'] 'transaction_id': params['transaction_id']
} }
if params['return_code'] == 'SUCCESS' and params['result_code'] == 'SUCCESS': if params['return_code'] == 'SUCCESS' and params['result_code'] == 'SUCCESS':

View File

@@ -21,7 +21,7 @@ class PaymentParamsOut(BaseModel):
timeStamp: str timeStamp: str
nonceStr: str nonceStr: str
package: str package: str
package: str paySign: str
signType: str signType: str

View File

@@ -9,6 +9,7 @@ import logging
from typing import List from typing import List
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.base import schema as base_schema from core.base import schema as base_schema
@@ -38,6 +39,24 @@ async def get_products(
): ):
db_crud = CRUD(db_session, Product) db_crud = CRUD(db_session, Product)
products = await db_crud.get_all(filters={'category': category}) 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 { return {
'data': products 'data': products
} }

View File

@@ -10,10 +10,14 @@ from pydantic import BaseModel
from core.base.schema import ProductCategory from core.base.schema import ProductCategory
class ProductOut(BaseModel): class Product(BaseModel):
id: int id: int
name: str name: str
category: ProductCategory category: ProductCategory
price: int price: int
number: int number: int
description: str | None description: str | None
class ProductOut(Product):
gift_product: Product | None