diff --git a/app.py b/app.py
index 186bdb6d..7cf4d8ce 100755
--- a/app.py
+++ b/app.py
@@ -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")
diff --git a/migrations/add_promo_code_tables.sql b/migrations/add_promo_code_tables.sql
new file mode 100644
index 00000000..d1b0fd4e
--- /dev/null
+++ b/migrations/add_promo_code_tables.sql
@@ -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;
diff --git a/src/components/Subscription/SubscriptionContent.js b/src/components/Subscription/SubscriptionContent.js
index 79c9b351..c621516e 100644
--- a/src/components/Subscription/SubscriptionContent.js
+++ b/src/components/Subscription/SubscriptionContent.js
@@ -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)
}
@@ -1084,14 +1203,62 @@ export default function SubscriptionContent() {
计费周期:
{selectedCycle === 'monthly' ? '按月付费' : '按年付费'}
-
-
- 应付金额:
-
- ¥{getCurrentPrice(selectedPlan).toFixed(2)}
+
+ {/* 价格明细 */}
+
+
+ {priceInfo && priceInfo.is_upgrade && (
+
+
+
+
+ {priceInfo.upgrade_type === 'plan_upgrade' ? '套餐升级' :
+ priceInfo.upgrade_type === 'cycle_change' ? '周期变更' : '套餐和周期调整'}
+
+
+
+
+ 当前订阅: {priceInfo.current_plan === 'pro' ? 'Pro版' : 'Max版'} ({priceInfo.current_cycle === 'monthly' ? '月付' : '年付'})
+
+
+ 剩余价值:
+ ¥{priceInfo.remaining_value.toFixed(2)}
+
+
+
+ )}
+
+
+
+ {priceInfo && priceInfo.is_upgrade ? '新套餐价格:' : '套餐价格:'}
+
+
+ ¥{priceInfo ? priceInfo.new_plan_price.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
- {getSavingsText(selectedPlan) && (
+
+ {priceInfo && priceInfo.is_upgrade && priceInfo.remaining_value > 0 && (
+
+ 已付剩余抵扣:
+ -¥{priceInfo.remaining_value.toFixed(2)}
+
+ )}
+
+ {priceInfo && priceInfo.discount_amount > 0 && (
+
+ 优惠码折扣:
+ -¥{priceInfo.discount_amount.toFixed(2)}
+
+ )}
+
+
+
+ 实付金额:
+
+ ¥{priceInfo ? priceInfo.final_amount.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
+
+
+ {getSavingsText(selectedPlan) && !priceInfo?.is_upgrade && (
{getSavingsText(selectedPlan)}
@@ -1104,6 +1271,53 @@ export default function SubscriptionContent() {
)}
+ {/* 优惠码输入 */}
+ {selectedPlan && (
+
+
+ {
+ setPromoCode(e.target.value.toUpperCase());
+ setPromoCodeError('');
+ }}
+ size="md"
+ isDisabled={promoCodeApplied}
+ />
+
+
+ {promoCodeError && (
+
+ {promoCodeError}
+
+ )}
+ {promoCodeApplied && priceInfo && (
+
+
+
+ 优惠码已应用!节省 ¥{priceInfo.discount_amount.toFixed(2)}
+
+
+
+ )}
+
+ )}
+