加入优惠码机制,预置3个优惠码

This commit is contained in:
2025-11-05 14:39:20 +08:00
parent bf89c0e13e
commit 1361a2b5b2
3 changed files with 823 additions and 31 deletions

480
app.py
View File

@@ -776,6 +776,10 @@ class PaymentOrder(db.Model):
'plan_name': self.plan_name,
'billing_cycle': self.billing_cycle,
'amount': float(self.amount) if self.amount else 0,
'original_amount': float(self.original_amount) if hasattr(self, 'original_amount') and self.original_amount else None,
'discount_amount': float(self.discount_amount) if hasattr(self, 'discount_amount') and self.discount_amount else 0,
'promo_code': self.promo_code.code if hasattr(self, 'promo_code') and self.promo_code else None,
'is_upgrade': self.is_upgrade if hasattr(self, 'is_upgrade') else False,
'qr_code_url': self.qr_code_url,
'status': self.status,
'is_expired': self.is_expired(),
@@ -786,6 +790,107 @@ class PaymentOrder(db.Model):
}
class PromoCode(db.Model):
"""优惠码表"""
__tablename__ = 'promo_codes'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
description = db.Column(db.String(200), nullable=True)
# 折扣类型和值
discount_type = db.Column(db.String(20), nullable=False) # 'percentage' 或 'fixed_amount'
discount_value = db.Column(db.Numeric(10, 2), nullable=False)
# 适用范围
applicable_plans = db.Column(db.String(200), nullable=True) # JSON格式
applicable_cycles = db.Column(db.String(50), nullable=True) # JSON格式
min_amount = db.Column(db.Numeric(10, 2), nullable=True)
# 使用限制
max_uses = db.Column(db.Integer, nullable=True)
max_uses_per_user = db.Column(db.Integer, default=1)
current_uses = db.Column(db.Integer, default=0)
# 有效期
valid_from = db.Column(db.DateTime, nullable=False)
valid_until = db.Column(db.DateTime, nullable=False)
# 状态
is_active = db.Column(db.Boolean, default=True)
created_by = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
def to_dict(self):
return {
'id': self.id,
'code': self.code,
'description': self.description,
'discount_type': self.discount_type,
'discount_value': float(self.discount_value) if self.discount_value else 0,
'applicable_plans': json.loads(self.applicable_plans) if self.applicable_plans else None,
'applicable_cycles': json.loads(self.applicable_cycles) if self.applicable_cycles else None,
'min_amount': float(self.min_amount) if self.min_amount else None,
'max_uses': self.max_uses,
'max_uses_per_user': self.max_uses_per_user,
'current_uses': self.current_uses,
'valid_from': self.valid_from.isoformat() if self.valid_from else None,
'valid_until': self.valid_until.isoformat() if self.valid_until else None,
'is_active': self.is_active
}
class PromoCodeUsage(db.Model):
"""优惠码使用记录表"""
__tablename__ = 'promo_code_usage'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=False)
user_id = db.Column(db.Integer, nullable=False, index=True)
order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False)
original_amount = db.Column(db.Numeric(10, 2), nullable=False)
discount_amount = db.Column(db.Numeric(10, 2), nullable=False)
final_amount = db.Column(db.Numeric(10, 2), nullable=False)
used_at = db.Column(db.DateTime, default=beijing_now)
# 关系
promo_code = db.relationship('PromoCode', backref='usages')
order = db.relationship('PaymentOrder', backref='promo_usage')
class SubscriptionUpgrade(db.Model):
"""订阅升级/降级记录表"""
__tablename__ = 'subscription_upgrades'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
user_id = db.Column(db.Integer, nullable=False, index=True)
order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False)
# 原订阅信息
from_plan = db.Column(db.String(20), nullable=False)
from_cycle = db.Column(db.String(10), nullable=False)
from_end_date = db.Column(db.DateTime, nullable=True)
# 新订阅信息
to_plan = db.Column(db.String(20), nullable=False)
to_cycle = db.Column(db.String(10), nullable=False)
to_end_date = db.Column(db.DateTime, nullable=False)
# 价格计算
remaining_value = db.Column(db.Numeric(10, 2), nullable=False)
upgrade_amount = db.Column(db.Numeric(10, 2), nullable=False)
actual_amount = db.Column(db.Numeric(10, 2), nullable=False)
upgrade_type = db.Column(db.String(20), nullable=False) # 'plan_upgrade', 'cycle_change', 'both'
created_at = db.Column(db.DateTime, default=beijing_now)
# 关系
order = db.relationship('PaymentOrder', backref='upgrade_record')
# ============================================
# 模拟盘相关模型
# ============================================
@@ -982,8 +1087,15 @@ def get_user_subscription_safe(user_id):
return DefaultSub()
def activate_user_subscription(user_id, plan_type, billing_cycle):
"""激活用户订阅"""
def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False):
"""激活用户订阅
Args:
user_id: 用户ID
plan_type: 套餐类型
billing_cycle: 计费周期
extend_from_now: 是否从当前时间开始延长(用于升级场景)
"""
try:
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
if not subscription:
@@ -993,7 +1105,9 @@ def activate_user_subscription(user_id, plan_type, billing_cycle):
subscription.subscription_type = plan_type
subscription.subscription_status = 'active'
subscription.billing_cycle = billing_cycle
subscription.start_date = beijing_now()
if not extend_from_now or not subscription.start_date:
subscription.start_date = beijing_now()
if billing_cycle == 'monthly':
subscription.end_date = beijing_now() + timedelta(days=30)
@@ -1007,6 +1121,195 @@ def activate_user_subscription(user_id, plan_type, billing_cycle):
return None
def validate_promo_code(code, plan_name, billing_cycle, amount, user_id):
"""验证优惠码
Returns:
tuple: (promo_code_obj, error_message)
"""
try:
promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first()
if not promo:
return None, "优惠码不存在或已失效"
# 检查有效期
now = beijing_now()
if now < promo.valid_from:
return None, "优惠码尚未生效"
if now > promo.valid_until:
return None, "优惠码已过期"
# 检查使用次数
if promo.max_uses and promo.current_uses >= promo.max_uses:
return None, "优惠码已被使用完"
# 检查每用户使用次数
if promo.max_uses_per_user:
user_usage_count = PromoCodeUsage.query.filter_by(
promo_code_id=promo.id,
user_id=user_id
).count()
if user_usage_count >= promo.max_uses_per_user:
return None, f"您已使用过此优惠码(限用{promo.max_uses_per_user}次)"
# 检查适用套餐
if promo.applicable_plans:
try:
applicable = json.loads(promo.applicable_plans)
if plan_name not in applicable:
return None, "该优惠码不适用于此套餐"
except:
pass
# 检查适用周期
if promo.applicable_cycles:
try:
applicable = json.loads(promo.applicable_cycles)
if billing_cycle not in applicable:
return None, "该优惠码不适用于此计费周期"
except:
pass
# 检查最低消费
if promo.min_amount and amount < float(promo.min_amount):
return None, f"需满{float(promo.min_amount):.2f}元才可使用此优惠码"
return promo, None
except Exception as e:
return None, f"验证优惠码时出错: {str(e)}"
def calculate_discount(promo_code, amount):
"""计算优惠金额"""
try:
if promo_code.discount_type == 'percentage':
discount = amount * (float(promo_code.discount_value) / 100)
else: # fixed_amount
discount = float(promo_code.discount_value)
# 确保折扣不超过总金额
return min(discount, amount)
except:
return 0
def calculate_remaining_value(subscription, current_plan):
"""计算当前订阅的剩余价值"""
try:
if not subscription or not subscription.end_date:
return 0
now = beijing_now()
if subscription.end_date <= now:
return 0
days_left = (subscription.end_date - now).days
if subscription.billing_cycle == 'monthly':
daily_value = float(current_plan.monthly_price) / 30
else: # yearly
daily_value = float(current_plan.yearly_price) / 365
return daily_value * days_left
except:
return 0
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
"""计算升级所需价格
Returns:
dict: 包含价格计算结果的字典
"""
try:
# 1. 获取当前订阅
current_sub = UserSubscription.query.filter_by(user_id=user_id).first()
# 2. 获取目标套餐
to_plan = SubscriptionPlan.query.filter_by(name=to_plan_name, is_active=True).first()
if not to_plan:
return {'error': '目标套餐不存在'}
# 3. 计算目标套餐价格
new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price)
# 4. 如果是新订阅(非升级)
if not current_sub or current_sub.subscription_type == 'free':
result = {
'is_upgrade': False,
'new_plan_price': new_price,
'remaining_value': 0,
'upgrade_amount': new_price,
'original_amount': new_price,
'discount_amount': 0,
'final_amount': new_price,
'promo_code': None
}
# 应用优惠码
if promo_code:
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id)
if promo:
discount = calculate_discount(promo, new_price)
result['discount_amount'] = discount
result['final_amount'] = new_price - discount
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
return result
# 5. 升级场景:计算剩余价值
current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first()
if not current_plan:
return {'error': '当前套餐信息不存在'}
remaining_value = calculate_remaining_value(current_sub, current_plan)
# 6. 计算升级差价
upgrade_amount = max(0, new_price - remaining_value)
# 7. 判断升级类型
upgrade_type = 'new'
if current_sub.subscription_type != to_plan_name and current_sub.billing_cycle != to_cycle:
upgrade_type = 'both'
elif current_sub.subscription_type != to_plan_name:
upgrade_type = 'plan_upgrade'
elif current_sub.billing_cycle != to_cycle:
upgrade_type = 'cycle_change'
result = {
'is_upgrade': True,
'upgrade_type': upgrade_type,
'current_plan': current_sub.subscription_type,
'current_cycle': current_sub.billing_cycle,
'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None,
'new_plan_price': new_price,
'remaining_value': remaining_value,
'upgrade_amount': upgrade_amount,
'original_amount': upgrade_amount,
'discount_amount': 0,
'final_amount': upgrade_amount,
'promo_code': None
}
# 8. 应用优惠码
if promo_code and upgrade_amount > 0:
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id)
if promo:
discount = calculate_discount(promo, upgrade_amount)
result['discount_amount'] = discount
result['final_amount'] = upgrade_amount - discount
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
return result
except Exception as e:
return {'error': str(e)}
def initialize_subscription_plans_safe():
"""安全地初始化订阅套餐"""
try:
@@ -1189,9 +1492,90 @@ def get_subscription_info():
})
@app.route('/api/promo-code/validate', methods=['POST'])
def validate_promo_code_api():
"""验证优惠码"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
data = request.get_json()
code = data.get('code', '').strip()
plan_name = data.get('plan_name')
billing_cycle = data.get('billing_cycle')
amount = data.get('amount', 0)
if not code or not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
# 验证优惠码
promo, error = validate_promo_code(code, plan_name, billing_cycle, amount, session['user_id'])
if error:
return jsonify({
'success': False,
'valid': False,
'error': error
})
# 计算折扣
discount_amount = calculate_discount(promo, amount)
final_amount = amount - discount_amount
return jsonify({
'success': True,
'valid': True,
'promo_code': promo.to_dict(),
'discount_amount': discount_amount,
'final_amount': final_amount
})
except Exception as e:
return jsonify({
'success': False,
'error': f'验证失败: {str(e)}'
}), 500
@app.route('/api/subscription/calculate-price', methods=['POST'])
def calculate_subscription_price():
"""计算订阅价格(支持升级和优惠码)"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
data = request.get_json()
to_plan = data.get('to_plan')
to_cycle = data.get('to_cycle')
promo_code = data.get('promo_code', '').strip() or None
if not to_plan or not to_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
# 计算价格
result = calculate_upgrade_price(session['user_id'], to_plan, to_cycle, promo_code)
if 'error' in result:
return jsonify({
'success': False,
'error': result['error']
}), 400
return jsonify({
'success': True,
'data': result
})
except Exception as e:
return jsonify({
'success': False,
'error': f'计算失败: {str(e)}'
}), 500
@app.route('/api/payment/create-order', methods=['POST'])
def create_payment_order():
"""创建支付订单"""
"""创建支付订单(支持升级和优惠码)"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
@@ -1199,23 +1583,21 @@ def create_payment_order():
data = request.get_json()
plan_name = data.get('plan_name')
billing_cycle = data.get('billing_cycle')
promo_code = data.get('promo_code', '').strip() or None
if not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
# 获取套餐信息
try:
plan = SubscriptionPlan.query.filter_by(name=plan_name, is_active=True).first()
if not plan:
# 如果表不存在,使用默认价格
prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}}
amount = prices.get(plan_name, {}).get(billing_cycle, 0.01)
else:
amount = plan.monthly_price if billing_cycle == 'monthly' else plan.yearly_price
except:
# 默认价格
prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}}
amount = prices.get(plan_name, {}).get(billing_cycle, 0.01)
# 计算价格(包括升级和优惠码)
price_result = calculate_upgrade_price(session['user_id'], plan_name, billing_cycle, promo_code)
if 'error' in price_result:
return jsonify({'success': False, 'error': price_result['error']}), 400
amount = price_result['final_amount']
original_amount = price_result['original_amount']
discount_amount = price_result['discount_amount']
is_upgrade = price_result.get('is_upgrade', False)
# 创建订单
try:
@@ -1225,10 +1607,52 @@ def create_payment_order():
billing_cycle=billing_cycle,
amount=amount
)
# 添加扩展字段(使用动态属性)
if hasattr(order, 'original_amount') or True: # 兼容性检查
order.original_amount = original_amount
order.discount_amount = discount_amount
order.is_upgrade = is_upgrade
# 如果使用了优惠码,关联优惠码
if promo_code and price_result.get('promo_code'):
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
if promo_obj:
order.promo_code_id = promo_obj.id
# 如果是升级,记录原套餐信息
if is_upgrade:
order.upgrade_from_plan = price_result.get('current_plan')
db.session.add(order)
db.session.commit()
# 如果是升级订单,创建升级记录
if is_upgrade and price_result.get('upgrade_type'):
try:
upgrade_record = SubscriptionUpgrade(
user_id=session['user_id'],
order_id=order.id,
from_plan=price_result['current_plan'],
from_cycle=price_result['current_cycle'],
from_end_date=datetime.fromisoformat(price_result['current_end_date']) if price_result.get('current_end_date') else None,
to_plan=plan_name,
to_cycle=billing_cycle,
to_end_date=beijing_now() + timedelta(days=365 if billing_cycle == 'yearly' else 30),
remaining_value=price_result['remaining_value'],
upgrade_amount=price_result['upgrade_amount'],
actual_amount=amount,
upgrade_type=price_result['upgrade_type']
)
db.session.add(upgrade_record)
db.session.commit()
except Exception as e:
print(f"创建升级记录失败: {e}")
# 不影响主流程
except Exception as e:
return jsonify({'success': False, 'error': '订单创建失败'}), 500
db.session.rollback()
return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500
# 尝试调用真实的微信支付API
try:
@@ -1420,6 +1844,26 @@ def force_update_order_status(order_id):
# 激活用户订阅
activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle)
# 记录优惠码使用(如果使用了优惠码)
if hasattr(order, 'promo_code_id') and order.promo_code_id:
try:
promo_usage = PromoCodeUsage(
promo_code_id=order.promo_code_id,
user_id=order.user_id,
order_id=order.id,
original_amount=order.original_amount if hasattr(order, 'original_amount') else order.amount,
discount_amount=order.discount_amount if hasattr(order, 'discount_amount') else 0,
final_amount=order.amount
)
db.session.add(promo_usage)
# 更新优惠码使用次数
promo = PromoCode.query.get(order.promo_code_id)
if promo:
promo.current_uses = (promo.current_uses or 0) + 1
except Exception as e:
print(f"记录优惠码使用失败: {e}")
db.session.commit()
print(f"✅ 订单状态强制更新成功: {old_status} -> paid")

View File

@@ -0,0 +1,134 @@
-- 数据库迁移脚本:添加优惠码和订阅升级相关表
-- 执行时间2025-xx-xx
-- 作者Claude Code
-- 说明:此脚本添加了优惠码、优惠码使用记录和订阅升级记录三张新表,并扩展了 payment_orders 表
-- ============================================
-- 1. 创建优惠码表
-- ============================================
CREATE TABLE IF NOT EXISTS `promo_codes` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`code` VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码(唯一)',
`description` VARCHAR(200) DEFAULT NULL COMMENT '优惠码描述',
-- 折扣类型和值
`discount_type` VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage百分比 或 fixed_amount固定金额',
`discount_value` DECIMAL(10, 2) NOT NULL COMMENT '折扣值',
-- 适用范围
`applicable_plans` VARCHAR(200) DEFAULT NULL COMMENT '适用套餐JSON格式如 ["pro", "max"]null表示全部适用',
`applicable_cycles` VARCHAR(50) DEFAULT NULL COMMENT '适用周期JSON格式如 ["monthly", "yearly"]null表示全部适用',
`min_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '最低消费金额',
-- 使用限制
`max_uses` INT DEFAULT NULL COMMENT '最大使用次数null表示无限制',
`max_uses_per_user` INT DEFAULT 1 COMMENT '每个用户最多使用次数',
`current_uses` INT DEFAULT 0 COMMENT '当前已使用次数',
-- 有效期
`valid_from` DATETIME NOT NULL COMMENT '生效时间',
`valid_until` DATETIME NOT NULL COMMENT '失效时间',
-- 状态
`is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用',
`created_by` INT DEFAULT NULL COMMENT '创建人管理员ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_code (`code`),
INDEX idx_valid_dates (`valid_from`, `valid_until`),
INDEX idx_is_active (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
-- ============================================
-- 2. 创建优惠码使用记录表
-- ============================================
CREATE TABLE IF NOT EXISTS `promo_code_usage` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`promo_code_id` INT NOT NULL COMMENT '优惠码ID',
`user_id` INT NOT NULL COMMENT '用户ID',
`order_id` INT NOT NULL COMMENT '订单ID',
`original_amount` DECIMAL(10, 2) NOT NULL COMMENT '原价',
`discount_amount` DECIMAL(10, 2) NOT NULL COMMENT '优惠金额',
`final_amount` DECIMAL(10, 2) NOT NULL COMMENT '实付金额',
`used_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '使用时间',
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
INDEX idx_user_id (`user_id`),
INDEX idx_promo_code_id (`promo_code_id`),
INDEX idx_order_id (`order_id`),
INDEX idx_used_at (`used_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
-- ============================================
-- 3. 创建订阅升级记录表
-- ============================================
CREATE TABLE IF NOT EXISTS `subscription_upgrades` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL COMMENT '用户ID',
`order_id` INT NOT NULL COMMENT '订单ID',
-- 原订阅信息
`from_plan` VARCHAR(20) NOT NULL COMMENT '原套餐',
`from_cycle` VARCHAR(10) NOT NULL COMMENT '原周期',
`from_end_date` DATETIME DEFAULT NULL COMMENT '原到期日',
-- 新订阅信息
`to_plan` VARCHAR(20) NOT NULL COMMENT '新套餐',
`to_cycle` VARCHAR(10) NOT NULL COMMENT '新周期',
`to_end_date` DATETIME NOT NULL COMMENT '新到期日',
-- 价格计算
`remaining_value` DECIMAL(10, 2) NOT NULL COMMENT '剩余价值',
`upgrade_amount` DECIMAL(10, 2) NOT NULL COMMENT '升级应付金额',
`actual_amount` DECIMAL(10, 2) NOT NULL COMMENT '实际支付金额',
`upgrade_type` VARCHAR(20) NOT NULL COMMENT '升级类型: plan_upgrade套餐升级, cycle_change周期变更, both都变更',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
INDEX idx_user_id (`user_id`),
INDEX idx_order_id (`order_id`),
INDEX idx_created_at (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅升级/降级记录表';
-- ============================================
-- 4. 扩展 payment_orders 表(添加新字段)
-- ============================================
-- 注意:这些字段是可选的扩展,用于记录优惠码和升级信息
-- 如果字段已存在会报错,可以忽略
ALTER TABLE `payment_orders`
ADD COLUMN `promo_code_id` INT DEFAULT NULL COMMENT '使用的优惠码ID' AFTER `remark`,
ADD COLUMN `original_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '原价(使用优惠码前)' AFTER `promo_code_id`,
ADD COLUMN `discount_amount` DECIMAL(10, 2) DEFAULT 0 COMMENT '优惠金额' AFTER `original_amount`,
ADD COLUMN `is_upgrade` BOOLEAN DEFAULT FALSE COMMENT '是否为升级订单' AFTER `discount_amount`,
ADD COLUMN `upgrade_from_plan` VARCHAR(20) DEFAULT NULL COMMENT '从哪个套餐升级' AFTER `is_upgrade`;
-- 添加外键约束
ALTER TABLE `payment_orders`
ADD CONSTRAINT `fk_payment_orders_promo_code`
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE SET NULL;
-- ============================================
-- 5. 插入示例优惠码(供测试使用)
-- ============================================
-- 10% 折扣优惠码,适用所有套餐和周期
INSERT INTO `promo_codes`
(`code`, `description`, `discount_type`, `discount_value`, `applicable_plans`, `applicable_cycles`, `min_amount`, `max_uses`, `max_uses_per_user`, `valid_from`, `valid_until`, `is_active`)
VALUES
('WELCOME10', '新用户欢迎优惠 - 10%折扣', 'percentage', 10.00, NULL, NULL, NULL, NULL, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
('ANNUAL20', '年付专享 - 20%折扣', 'percentage', 20.00, NULL, '["yearly"]', NULL, 100, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
('SUMMER50', '夏季促销 - 减免50元', 'fixed_amount', 50.00, '["max"]', NULL, 100.00, 50, 1, NOW(), DATE_ADD(NOW(), INTERVAL 3 MONTH), TRUE);
-- 完成
SELECT 'Migration completed successfully!' AS status;

View File

@@ -29,6 +29,9 @@ import {
Td,
Heading,
Collapse,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react';
import React, { useState, useEffect } from 'react';
import { logger } from '../../utils/logger';
@@ -85,6 +88,13 @@ export default function SubscriptionContent() {
const [forceUpdating, setForceUpdating] = useState(false);
const [openFaqIndex, setOpenFaqIndex] = useState(null);
// 优惠码相关state
const [promoCode, setPromoCode] = useState('');
const [promoCodeApplied, setPromoCodeApplied] = useState(false);
const [promoCodeError, setPromoCodeError] = useState('');
const [validatingPromo, setValidatingPromo] = useState(false);
const [priceInfo, setPriceInfo] = useState(null); // 价格信息(包含升级计算)
// 加载订阅套餐数据
useEffect(() => {
fetchSubscriptionPlans();
@@ -149,7 +159,88 @@ export default function SubscriptionContent() {
}
};
const handleSubscribe = (plan) => {
// 计算价格(包含升级和优惠码)
const calculatePrice = async (plan, cycle, promoCodeValue = null) => {
try {
const response = await fetch('/api/subscription/calculate-price', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
to_plan: plan.name,
to_cycle: cycle,
promo_code: promoCodeValue || null
})
});
if (response.ok) {
const data = await response.json();
if (data.success) {
setPriceInfo(data.data);
return data.data;
}
}
return null;
} catch (error) {
logger.error('SubscriptionContent', 'calculatePrice', error);
return null;
}
};
// 验证优惠码
const handleValidatePromoCode = async () => {
if (!promoCode.trim()) {
setPromoCodeError('请输入优惠码');
return;
}
if (!selectedPlan) {
setPromoCodeError('请先选择套餐');
return;
}
setValidatingPromo(true);
setPromoCodeError('');
try {
// 重新计算价格,包含优惠码
const result = await calculatePrice(selectedPlan, selectedCycle, promoCode);
if (result && !result.promo_error) {
setPromoCodeApplied(true);
toast({
title: '优惠码已应用',
description: `节省 ¥${result.discount_amount.toFixed(2)}`,
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
setPromoCodeError(result?.promo_error || '优惠码无效');
setPromoCodeApplied(false);
}
} catch (error) {
setPromoCodeError('验证失败,请重试');
setPromoCodeApplied(false);
} finally {
setValidatingPromo(false);
}
};
// 移除优惠码
const handleRemovePromoCode = async () => {
setPromoCode('');
setPromoCodeApplied(false);
setPromoCodeError('');
// 重新计算价格(不含优惠码)
if (selectedPlan) {
await calculatePrice(selectedPlan, selectedCycle, null);
}
};
const handleSubscribe = async (plan) => {
if (!user) {
toast({
title: '请先登录',
@@ -178,6 +269,10 @@ export default function SubscriptionContent() {
);
setSelectedPlan(plan);
// 计算价格(包含升级判断)
await calculatePrice(plan, selectedCycle, promoCodeApplied ? promoCode : null);
onPaymentModalOpen();
};
@@ -186,7 +281,7 @@ export default function SubscriptionContent() {
setLoading(true);
try {
const price = selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price;
const price = priceInfo?.final_amount || (selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price);
// 🎯 追踪支付发起
subscriptionEvents.trackPaymentInitiated({
@@ -205,7 +300,8 @@ export default function SubscriptionContent() {
credentials: 'include',
body: JSON.stringify({
plan_name: selectedPlan.name,
billing_cycle: selectedCycle
billing_cycle: selectedCycle,
promo_code: promoCodeApplied ? promoCode : null
})
});
@@ -488,6 +584,27 @@ export default function SubscriptionContent() {
return `年付节省 ${percentage}%`;
};
// 获取按钮文字(根据用户当前订阅判断是升级还是新订阅)
const getButtonText = (plan, user) => {
if (!user || user.subscription_type === 'free') {
return `选择 ${plan.display_name}`;
}
// 判断是否为升级
const planLevels = { 'free': 0, 'pro': 1, 'max': 2 };
const currentLevel = planLevels[user.subscription_type] || 0;
const targetLevel = planLevels[plan.name] || 0;
if (targetLevel > currentLevel) {
return `升级至 ${plan.display_name}`;
} else if (targetLevel < currentLevel) {
return `切换至 ${plan.display_name}`;
} else {
// 同级别,可能是切换周期
return `切换至 ${plan.display_name}`;
}
};
// 统一的功能列表定义 - 基于商业定价(10月15日)文档
const allFeatures = [
// 新闻催化分析模块
@@ -838,7 +955,8 @@ export default function SubscriptionContent() {
onClick={() => handleSubscribe(plan)}
isDisabled={
user?.subscription_type === plan.name &&
user?.subscription_status === 'active'
user?.subscription_status === 'active' &&
user?.billing_cycle === selectedCycle
}
_hover={{
transform: 'scale(1.02)',
@@ -846,9 +964,10 @@ export default function SubscriptionContent() {
transition="all 0.2s"
>
{user?.subscription_type === plan.name &&
user?.subscription_status === 'active'
? '✓ 已订阅'
: `选择 ${plan.display_name}`
user?.subscription_status === 'active' &&
user?.billing_cycle === selectedCycle
? '✓ 当前套餐'
: getButtonText(plan, user)
}
</Button>
</VStack>
@@ -1084,14 +1203,62 @@ export default function SubscriptionContent() {
<Text color={secondaryText}>计费周期:</Text>
<Text>{selectedCycle === 'monthly' ? '按月付费' : '按年付费'}</Text>
</Flex>
<Divider />
<Flex justify="space-between" align="baseline">
<Text color={secondaryText}>应付金额:</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
¥{getCurrentPrice(selectedPlan).toFixed(2)}
{/* 价格明细 */}
<Divider my={2} />
{priceInfo && priceInfo.is_upgrade && (
<Box bg="blue.50" p={3} borderRadius="md" mb={2}>
<HStack spacing={2} mb={2}>
<Icon as={FaCheck} color="blue.500" boxSize={4} />
<Text fontSize="sm" fontWeight="bold" color="blue.700">
{priceInfo.upgrade_type === 'plan_upgrade' ? '套餐升级' :
priceInfo.upgrade_type === 'cycle_change' ? '周期变更' : '套餐和周期调整'}
</Text>
</HStack>
<VStack spacing={1} align="stretch" fontSize="xs">
<Flex justify="space-between" color="gray.600">
<Text>当前订阅: {priceInfo.current_plan === 'pro' ? 'Pro版' : 'Max版'} ({priceInfo.current_cycle === 'monthly' ? '月付' : '年付'})</Text>
</Flex>
<Flex justify="space-between" color="gray.600">
<Text>剩余价值:</Text>
<Text>¥{priceInfo.remaining_value.toFixed(2)}</Text>
</Flex>
</VStack>
</Box>
)}
<Flex justify="space-between">
<Text color={secondaryText}>
{priceInfo && priceInfo.is_upgrade ? '新套餐价格:' : '套餐价格:'}
</Text>
<Text fontWeight="medium">
¥{priceInfo ? priceInfo.new_plan_price.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
</Text>
</Flex>
{getSavingsText(selectedPlan) && (
{priceInfo && priceInfo.is_upgrade && priceInfo.remaining_value > 0 && (
<Flex justify="space-between" color="blue.600">
<Text>已付剩余抵扣:</Text>
<Text>-¥{priceInfo.remaining_value.toFixed(2)}</Text>
</Flex>
)}
{priceInfo && priceInfo.discount_amount > 0 && (
<Flex justify="space-between" color="green.600">
<Text>优惠码折扣:</Text>
<Text>-¥{priceInfo.discount_amount.toFixed(2)}</Text>
</Flex>
)}
<Divider />
<Flex justify="space-between" align="baseline">
<Text fontSize="lg" fontWeight="bold">实付金额:</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
¥{priceInfo ? priceInfo.final_amount.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
</Text>
</Flex>
{getSavingsText(selectedPlan) && !priceInfo?.is_upgrade && (
<Badge colorScheme="green" alignSelf="flex-end" fontSize="xs">
{getSavingsText(selectedPlan)}
</Badge>
@@ -1104,6 +1271,53 @@ export default function SubscriptionContent() {
</Box>
)}
{/* 优惠码输入 */}
{selectedPlan && (
<Box>
<HStack spacing={2}>
<Input
placeholder="输入优惠码(可选)"
value={promoCode}
onChange={(e) => {
setPromoCode(e.target.value.toUpperCase());
setPromoCodeError('');
}}
size="md"
isDisabled={promoCodeApplied}
/>
<Button
colorScheme="purple"
onClick={handleValidatePromoCode}
isLoading={validatingPromo}
isDisabled={!promoCode || promoCodeApplied}
minW="80px"
>
应用
</Button>
</HStack>
{promoCodeError && (
<Text color="red.500" fontSize="sm" mt={2}>
{promoCodeError}
</Text>
)}
{promoCodeApplied && priceInfo && (
<HStack mt={2} p={2} bg="green.50" borderRadius="md">
<Icon as={FaCheck} color="green.500" />
<Text color="green.700" fontSize="sm" fontWeight="medium" flex={1}>
优惠码已应用节省 ¥{priceInfo.discount_amount.toFixed(2)}
</Text>
<Icon
as={FaTimes}
color="gray.500"
cursor="pointer"
onClick={handleRemovePromoCode}
_hover={{ color: 'red.500' }}
/>
</HStack>
)}
</Box>
)}
<Button
colorScheme="green"
size="lg"