diff --git a/blueprints/admin.py b/blueprints/admin.py index 3cf21c3..4b372b1 100644 --- a/blueprints/admin.py +++ b/blueprints/admin.py @@ -1,7 +1,7 @@ from flask import Blueprint, request, jsonify from datetime import timedelta from extensions import db -from models import User, Role, Permission, SystemDict, SystemNotification, Order, to_bj_time, get_bj_now +from models import User, Role, Permission, SystemDict, SystemNotification, Order, PointsGrant, InviteReward, to_bj_time, get_bj_now from middlewares.auth import permission_required from services.logger import system_logger @@ -91,7 +91,9 @@ def get_users(): "phone": u.phone, "role": u.role.name if u.role else "未分配", "role_id": u.role.id if u.role else None, - "is_banned": u.is_banned + "is_banned": u.is_banned, + "points": u.points, + "created_at": u.created_at_bj.strftime('%Y-%m-%d %H:%M:%S') } for u in pagination.items], "total": pagination.total, "pages": pagination.pages, @@ -299,4 +301,301 @@ def get_orders(): "created_at": o.created_at_bj.strftime('%Y-%m-%d %H:%M:%S'), "paid_at": o.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if o.paid_at else None } for o in orders] - }) \ No newline at end of file + }) + +# --- 积分发放管理 --- +@admin_bp.route('/points/grant', methods=['POST']) +@permission_required('manage_system') +def grant_points(): + """给用户发放积分""" + from flask import session + data = request.json + user_id = data.get('user_id') + points = data.get('points') + reason = data.get('reason', '管理员手动发放') + + if not user_id or not points: + return jsonify({"error": "请提供用户ID和积分数"}), 400 + + try: + points = int(points) + if points <= 0: + return jsonify({"error": "积分数必须为正整数"}), 400 + except ValueError: + return jsonify({"error": "积分数必须为整数"}), 400 + + user = db.session.get(User, user_id) + if not user: + return jsonify({"error": "用户不存在"}), 404 + + admin_id = session.get('user_id') + + # 发放积分 + user.points += points + + # 记录发放记录 + grant = PointsGrant( + user_id=user_id, + points=points, + reason=reason, + admin_id=admin_id + ) + db.session.add(grant) + db.session.commit() + + system_logger.info(f"管理员发放积分", target_user_id=user_id, points=points, reason=reason) + return jsonify({"message": f"成功发放 {points} 积分给用户 {user.phone}"}) + +@admin_bp.route('/points/batch_grant', methods=['POST']) +@permission_required('manage_system') +def batch_grant_points(): + """批量给用户发放积分""" + from flask import session + data = request.json + phones_str = data.get('phones', '') + points = data.get('points') + reason = data.get('reason', '管理员批量发放') + + if not phones_str or not points: + return jsonify({"error": "请提供手机号列表和积分数"}), 400 + + try: + points = int(points) + if points <= 0: + return jsonify({"error": "积分数必须为正整数"}), 400 + except ValueError: + return jsonify({"error": "积分数必须为整数"}), 400 + + # 处理手机号列表(支持换行、逗号、空格分隔) + import re + phone_list = list(set(re.split(r'[,\n\s]+', phones_str.strip()))) + phone_list = [p for p in phone_list if re.match(r'^1[3-9]\d{9}$', p)] + + if not phone_list: + return jsonify({"error": "未识别到有效的手机号"}), 400 + + admin_id = session.get('user_id') + success_count = 0 + fail_phones = [] + + for phone in phone_list: + user = User.query.filter_by(phone=phone).first() + if user: + user.points += points + grant = PointsGrant( + user_id=user.id, + points=points, + reason=reason, + admin_id=admin_id + ) + db.session.add(grant) + success_count += 1 + else: + fail_phones.append(phone) + + db.session.commit() + + msg = f"操作完成。成功: {success_count} 人" + if fail_phones: + msg += f",失败: {len(fail_phones)} 人 (用户不存在)" + + system_logger.info(f"管理员批量发放积分", count=success_count, points=points) + return jsonify({ + "message": msg, + "success_count": success_count, + "fail_count": len(fail_phones), + "fail_phones": fail_phones + }) + +@admin_bp.route('/points/batch_grant_ids', methods=['POST']) +@permission_required('manage_system') +def batch_grant_by_ids(): + """通过用户ID列表批量发放积分""" + from flask import session + data = request.json + user_ids = data.get('user_ids', []) + points = data.get('points') + reason = data.get('reason', '管理员批量发放') + + if not user_ids or not points: + return jsonify({"error": "请选择用户并提供积分数"}), 400 + + try: + points = int(points) + if points <= 0: + return jsonify({"error": "积分数必须为正整数"}), 400 + except ValueError: + return jsonify({"error": "积分数必须为整数"}), 400 + + admin_id = session.get('user_id') + success_count = 0 + + for uid in user_ids: + user = db.session.get(User, uid) + if user: + user.points += points + grant = PointsGrant( + user_id=user.id, + points=points, + reason=reason, + admin_id=admin_id + ) + db.session.add(grant) + success_count += 1 + + db.session.commit() + + system_logger.info(f"管理员批量发放积分(按ID)", count=success_count, points=points) + return jsonify({"message": f"成功为 {success_count} 名用户发放了积分"}) + +@admin_bp.route('/points/global_grant', methods=['POST']) +@permission_required('manage_system') +def global_grant_points(): + """给全员发放积分""" + from flask import session + data = request.json + points = data.get('points') + reason = data.get('reason', '全员普惠') + + if not points: + return jsonify({"error": "请提供积分数"}), 400 + + try: + points = int(points) + if points <= 0: + return jsonify({"error": "积分数必须为正整数"}), 400 + except ValueError: + return jsonify({"error": "积分数必须为整数"}), 400 + + admin_id = session.get('user_id') + + # 获取所有用户 + users = User.query.all() + count = len(users) + + for user in users: + user.points += points + # 记录每人的发放明细(虽然量大,但为了对账建议记录;若用户量极大可优化为汇总记录) + grant = PointsGrant( + user_id=user.id, + points=points, + reason=reason, + admin_id=admin_id + ) + db.session.add(grant) + + db.session.commit() + + system_logger.info(f"管理员执行全员发放", user_count=count, points=points) + return jsonify({"message": f"成功为全站 {count} 名用户每人发放了 {points} 积分!"}) + +@admin_bp.route('/points/grants', methods=['GET']) +@permission_required('manage_system') +def get_points_grants(): + """获取积分发放记录""" + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + search = request.args.get('q', '').strip() + + query = PointsGrant.query.join(User, PointsGrant.user_id == User.id) + + if search: + query = query.filter(User.phone.like(f"%{search}%")) + + pagination = query.order_by(PointsGrant.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + return jsonify({ + "grants": [{ + "id": g.id, + "user_id": g.user_id, + "user_phone": g.user.phone if g.user else "未知", + "points": g.points, + "reason": g.reason, + "admin_phone": g.admin.phone if g.admin else "系统", + "created_at": g.created_at_bj.strftime('%Y-%m-%d %H:%M:%S') + } for g in pagination.items], + "total": pagination.total, + "pages": pagination.pages, + "current_page": pagination.page + }) + +@admin_bp.route('/invite/rewards', methods=['GET']) +@permission_required('manage_system') +def get_invite_rewards(): + """获取邀请奖励记录""" + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + search = request.args.get('q', '').strip() + + # 创建别名以处理多个关联 + from sqlalchemy.orm import aliased + Inviter = aliased(User) + Invitee = aliased(User) + + query = db.session.query(InviteReward).join(Inviter, InviteReward.inviter_id == Inviter.id).join(Invitee, InviteReward.invitee_id == Invitee.id) + + if search: + query = query.filter(db.or_( + Inviter.phone.like(f"%{search}%"), + Invitee.phone.like(f"%{search}%") + )) + + pagination = query.order_by(InviteReward.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + return jsonify({ + "rewards": [{ + "id": r.id, + "inviter_id": r.inviter_id, + "inviter_phone": r.inviter.phone if r.inviter else "未知", + "invitee_id": r.invitee_id, + "invitee_phone": r.invitee.phone if r.invitee else "未知", + "reward_points": r.reward_points, + "recharge_count": r.recharge_count, + "created_at": r.created_at_bj.strftime('%Y-%m-%d %H:%M:%S') + } for r in pagination.items], + "total": pagination.total, + "pages": pagination.pages, + "current_page": pagination.page + }) + +@admin_bp.route('/invite/stats', methods=['GET']) +@permission_required('manage_system') +def get_invite_stats(): + """获取邀请统计数据""" + # 总邀请人数 + total_invited = User.query.filter(User.invited_by.isnot(None)).count() + + # 总发放邀请奖励 + total_rewards = db.session.query(db.func.sum(InviteReward.reward_points)).scalar() or 0 + + # 总手动发放积分 + total_grants = db.session.query(db.func.sum(PointsGrant.points)).scalar() or 0 + + # 邀请排行榜(前10) + from sqlalchemy.orm import aliased + Inviter = aliased(User) + Invitee = aliased(User) + + top_inviters = db.session.query( + Inviter.id, + Inviter.phone, + db.func.count(Invitee.id).label('invite_count') + ).join(Invitee, Invitee.invited_by == Inviter.id)\ + .group_by(Inviter.id, Inviter.phone)\ + .order_by(db.desc('invite_count'))\ + .limit(10).all() + + return jsonify({ + "total_invited": total_invited, + "total_rewards": int(total_rewards), + "total_grants": int(total_grants), + "top_inviters": [{ + "user_id": t[0], + "phone": t[1], + "invite_count": t[2] + } for t in top_inviters] if top_inviters else [] + }) diff --git a/blueprints/auth.py b/blueprints/auth.py index 3babd7d..5ab478e 100644 --- a/blueprints/auth.py +++ b/blueprints/auth.py @@ -47,6 +47,12 @@ def admin_orders_page(): """全员订单管理页面""" return render_template('orders.html') +@auth_bp.route('/admin/points') +@admin_required +def admin_points_page(): + """积分发放管理页面""" + return render_template('points.html') + @auth_bp.route('/buy') def buy_page(): """购买积分页面""" @@ -88,6 +94,7 @@ def buy_page(): import datetime as dt_module return render_template('buy.html', + user=user, personal_orders=personal_orders, admin_orders=admin_orders, is_admin=is_admin, @@ -95,6 +102,23 @@ def buy_page(): success=success, order=order) +@auth_bp.route('/recharge_history') +def recharge_history_page(): + """充值历史记录页面""" + if 'user_id' not in session: + return redirect(url_for('auth.login_page')) + + from models import Order, User + user_id = session['user_id'] + user = db.session.get(User, user_id) + + # 获取用户所有充值记录 + orders = Order.query.filter_by(user_id=user_id).order_by(Order.created_at.desc()).all() + + return render_template('recharge_history.html', + user=user, + orders=orders) + @auth_bp.route('/api/auth/captcha') def get_captcha(): """获取图形验证码并存入 Redis""" @@ -177,6 +201,7 @@ def register(): phone = data.get('phone') code = data.get('code') password = data.get('password') + invite_code = data.get('invite_code', '').strip().upper() # 获取邀请码 import re if not phone or not re.match(r'^1[3-9]\d{9}$', phone): @@ -191,17 +216,38 @@ def register(): if User.query.filter_by(phone=phone).first(): system_logger.warning(f"注册失败: 手机号已存在", phone=phone) return jsonify({"error": "该手机号已注册"}), 400 + + # 验证邀请码(如果提供) + inviter = None + if invite_code: + inviter = User.query.filter_by(invite_code=invite_code).first() + if not inviter: + return jsonify({"error": "邀请码无效,请检查后重试"}), 400 # 获取默认角色:普通用户 from models import Role user_role = Role.query.filter_by(name='普通用户').first() - user = User(phone=phone, role=user_role) + # 生成唯一邀请码 + import random + import string + def generate_invite_code(): + while True: + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + if not User.query.filter_by(invite_code=code).first(): + return code + + user = User(phone=phone, role=user_role, invite_code=generate_invite_code()) + if inviter: + user.invited_by = inviter.id user.set_password(password) db.session.add(user) db.session.commit() - system_logger.info(f"用户注册成功", phone=phone, user_id=user.id) + if inviter: + system_logger.info(f"用户注册成功(通过邀请码)", phone=phone, user_id=user.id, inviter_id=inviter.id) + else: + system_logger.info(f"用户注册成功", phone=phone, user_id=user.id) return jsonify({"message": "注册成功"}) @auth_bp.route('/api/auth/login', methods=['POST']) @@ -314,7 +360,216 @@ def me(): "full_phone": phone, # 某些场景可能需要完整号 "api_key": user.api_key, "points": user.points, - "hide_custom_key": (not user.api_key) or user.has_used_points # Key为空或使用过积分,则隐藏自定义Key入口 + "hide_custom_key": (not user.api_key) or user.has_used_points, # Key为空或使用过积分,则隐藏自定义Key入口 + "invite_code": user.invite_code # 用户邀请码 + }) + +@auth_bp.route('/api/auth/invite_stats', methods=['GET']) +def get_my_invite_stats(): + """获取当前用户的邀请统计""" + user_id = session.get('user_id') + if not user_id: + return jsonify({"error": "请先登录"}), 401 + + user = db.session.get(User, user_id) + if not user: + return jsonify({"error": "用户不存在"}), 404 + + from models import InviteReward, Order + + # 我邀请的用户数 + invited_count = User.query.filter_by(invited_by=user_id).count() + + # 我获得的邀请奖励总积分 + total_rewards = db.session.query(db.func.sum(InviteReward.reward_points)).filter( + InviteReward.inviter_id == user_id + ).scalar() or 0 + + # 我邀请的用户列表(脱敏) + invited_users = User.query.filter_by(invited_by=user_id).order_by(User.created_at.desc()).limit(20).all() + + invited_list = [] + for u in invited_users: + # 脱敏手机号 + masked = u.phone[:3] + "****" + u.phone[-4:] if len(u.phone) >= 11 else u.phone + # 计算该用户给我带来的奖励 + user_rewards = db.session.query(db.func.sum(InviteReward.reward_points)).filter( + InviteReward.inviter_id == user_id, + InviteReward.invitee_id == u.id + ).scalar() or 0 + + invited_list.append({ + "phone": masked, + "registered_at": u.created_at_bj.strftime('%Y-%m-%d') if u.created_at else None, + "rewards_earned": int(user_rewards) + }) + + return jsonify({ + "invite_code": user.invite_code, + "invited_count": invited_count, + "total_rewards": int(total_rewards), + "invited_users": invited_list + }) + +@auth_bp.route('/api/auth/point_history', methods=['GET']) +def get_point_history(): + """获取当前用户的积分变动历史记录""" + user_id = session.get('user_id') + if not user_id: + return jsonify({"error": "请先登录"}), 401 + + from models import Order, PointsGrant, InviteReward, User + + history = [] + + # 1. 充值记录 + orders = Order.query.filter_by(user_id=user_id, status='PAID').all() + for o in orders: + history.append({ + "type": "recharge", + "type_label": "账户充值", + "amount": f"+{o.points}", + "desc": f"在线支付 ¥{o.amount}", + "time": o.paid_at_bj.strftime('%Y-%m-%d %H:%M') if o.paid_at_bj else o.created_at_bj.strftime('%Y-%m-%d %H:%M') + }) + + # 2. 手动发放记录 + grants = PointsGrant.query.filter_by(user_id=user_id).all() + for g in grants: + history.append({ + "type": "grant", + "type_label": "系统发放", + "amount": f"+{g.points}", + "desc": g.reason or "管理员手动调账", + "time": g.created_at_bj.strftime('%Y-%m-%d %H:%M') + }) + + # 3. 邀请奖励记录 + rewards = InviteReward.query.filter_by(inviter_id=user_id).all() + for r in rewards: + # 查找被邀请人信息 + invitee = db.session.get(User, r.invitee_id) + phone = invitee.phone if invitee else "未知用户" + masked = phone[:3] + "****" + phone[-4:] if len(phone) >= 11 else phone + + history.append({ + "type": "reward", + "type_label": "邀请奖励", + "amount": f"+{r.reward_points}", + "desc": f"好友({masked})充值返利", + "time": r.created_at_bj.strftime('%Y-%m-%d %H:%M') + }) + + # 4. 消费记录 (GenerationRecord) + from models import GenerationRecord + consumptions = GenerationRecord.query.filter_by(user_id=user_id).order_by(GenerationRecord.created_at.desc()).limit(100).all() + for c in consumptions: + # 截断 prompt + action = c.prompt[:20] + '...' if c.prompt and len(c.prompt) > 20 else (c.prompt or "AI生成") + history.append({ + "type": "consumption", + "type_label": "积分消费", + "amount": f"-{c.cost}", + "desc": f"[{c.model or 'Default'}] {action}", + "time": c.created_at_bj.strftime('%Y-%m-%d %H:%M') + }) + + # 按时间降序排序 + history.sort(key=lambda x: x['time'], reverse=True) + + # 手动分页 + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 5, type=int) + total = len(history) + start = (page - 1) * per_page + end = start + per_page + + import math + return jsonify({ + "details": history[start:end], + "total": total, + "pages": math.ceil(total / per_page), + "current_page": page + }) + +@auth_bp.route('/api/auth/point_stats', methods=['GET']) +def get_point_stats(): + """获取用户过去 7 天的积分变动统计(充值 vs 消费)""" + user_id = session.get('user_id') + if not user_id: + return jsonify({"error": "请先登录"}), 401 + + from models import Order, GenerationRecord, get_bj_now + from datetime import timedelta + + end_date = get_bj_now().date() + start_date = end_date - timedelta(days=6) + + labels = [] + consumption_data = [] + recharge_data = [] + + curr = start_date + while curr <= end_date: + labels.append(curr.strftime('%m-%d')) + + # 当天充值 (PAID 订单) + day_recharge = db.session.query(db.func.sum(Order.points)).filter( + Order.user_id == user_id, + Order.status == 'PAID', + db.func.date(Order.paid_at) == curr + ).scalar() or 0 + + # 当天消费 (生成记录) + day_consumption = db.session.query(db.func.sum(GenerationRecord.cost)).filter( + GenerationRecord.user_id == user_id, + db.func.date(GenerationRecord.created_at) == curr + ).scalar() or 0 + + recharge_data.append(int(day_recharge)) + consumption_data.append(int(day_consumption)) + + curr += timedelta(days=1) + + return jsonify({ + "labels": labels, + "consumption": consumption_data, + "recharge": recharge_data + }) + +@auth_bp.route('/api/auth/consumption_details', methods=['GET']) +def get_consumption_details(): + """获取用户积分消费明细记录""" + user_id = session.get('user_id') + if not user_id: + return jsonify({"error": "请先登录"}), 401 + + from models import GenerationRecord + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 10, type=int) + + pagination = GenerationRecord.query.filter_by(user_id=user_id).order_by(GenerationRecord.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + details = [] + for r in pagination.items: + # 截断 prompt 作为动作描述 + action = r.prompt[:30] + '...' if r.prompt and len(r.prompt) > 30 else (r.prompt or "生成图片") + details.append({ + "id": r.id, + "action": action, + "model": r.model or "Default", + "cost": f"-{r.cost}", + "time": r.created_at_bj.strftime('%Y-%m-%d %H:%M') + }) + + return jsonify({ + "details": details, + "total": pagination.total, + "pages": pagination.pages, + "current_page": pagination.page }) @auth_bp.route('/api/auth/change_password', methods=['POST']) @@ -361,6 +616,7 @@ def get_user_menu(user): {"name": "AI 视频创作", "icon": "video", "url": "/video", "perm": None}, {"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None}, {"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None}, + {"name": "积分发放管理", "icon": "gift", "url": "/admin/points", "perm": "manage_system"}, {"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"}, {"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"}, {"name": "系统通知管理", "icon": "megaphone", "url": "/notifications", "perm": "manage_notifications"}, diff --git a/blueprints/payment.py b/blueprints/payment.py index e38a469..5ce046e 100644 --- a/blueprints/payment.py +++ b/blueprints/payment.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, redirect, url_for, session, jsonify, render_template from extensions import db, redis_client -from models import Order, User, to_bj_time, get_bj_now +from models import Order, User, InviteReward, to_bj_time, get_bj_now from services.alipay_service import AlipayService from services.logger import system_logger import uuid @@ -16,6 +16,57 @@ POINTS_PACKAGES = { '5000': {'points': 5000, 'amount': 500.00}, } +def _process_success_order(order, trade_no): + """内部处理订单成功逻辑 (需在锁内调用,不包含 commit)""" + if order.status == 'PAID': + return False + + order.status = 'PAID' + order.trade_no = trade_no + order.paid_at = get_bj_now() + + # 增加用户积分 + user = db.session.get(User, order.user_id) + if user: + user.points += order.points + system_logger.info(f"订单支付成功", order_id=order.out_trade_no, points=order.points, user_id=user.id) + + # ========== 邀请奖励逻辑 ========== + if user.invited_by: + # 统计该被邀请人之前已经完成多少次充值(不含本次) + paid_count = Order.query.filter( + Order.user_id == user.id, + Order.status == 'PAID', + Order.id != order.id + ).count() + + # 只有前3次充值才有奖励 + if paid_count < 3: + inviter = db.session.get(User, user.invited_by) + if inviter: + # 计算10%奖励积分(四舍五入) + reward_points = round(order.points * 0.1) + if reward_points > 0: + inviter.points += reward_points + + # 记录邀请奖励 + invite_reward = InviteReward( + inviter_id=inviter.id, + invitee_id=user.id, + order_id=order.id, + reward_points=reward_points, + recharge_count=paid_count + 1 + ) + db.session.add(invite_reward) + system_logger.info( + f"邀请奖励发放成功", + inviter_id=inviter.id, + invitee_id=user.id, + reward_points=reward_points, + recharge_count=paid_count + 1 + ) + return True + @payment_bp.route('/create', methods=['POST']) def create_payment(): if 'user_id' not in session: @@ -88,17 +139,8 @@ def payment_return(): order = Order.query.filter_by(out_trade_no=out_trade_no).with_for_update().first() # 如果订单存在且状态为PENDING,则更新为PAID - if order and order.status == 'PENDING': - order.status = 'PAID' - order.trade_no = trade_no - order.paid_at = get_bj_now() - - # 增加用户积分 - user = db.session.get(User, order.user_id) - if user: - user.points += order.points - system_logger.info(f"同步回调-订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id) - + if order: + _process_success_order(order, trade_no) db.session.commit() elif order: # 订单已经是完成状态,不做处理 @@ -216,36 +258,28 @@ def api_sync_order(): order_locked = Order.query.filter_by(out_trade_no=out_trade_no).with_for_update().first() # 二次校验状态,防止异步回调/定时任务已经处理 - if order_locked and order_locked.status == 'PENDING': - order_locked.status = 'PAID' - order_locked.trade_no = alipay_result.get('trade_no') - if not order_locked.paid_at: - order_locked.paid_at = get_bj_now() - - # 增加用户积分 - user = db.session.get(User, order_locked.user_id) - if user: - user.points += order_locked.points - system_logger.info(f"主动查询-订单支付成功", order_id=out_trade_no, points=order_locked.points, user_id=user.id) - - db.session.commit() - - return jsonify({ - 'code': 200, - 'msg': '订单已支付,积分已增加', - 'status': 'PAID', - 'points': order_locked.points, - 'paid_at': order_locked.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') - }) - elif order_locked and order_locked.status == 'PAID': - # 订单已经被处理,直接返回 - return jsonify({ - 'code': 200, - 'msg': '订单已支付', - 'status': 'PAID', - 'points': order_locked.points, - 'paid_at': order_locked.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if order_locked.paid_at else None - }) + # 二次校验状态,防止异步回调/定时任务已经处理 + if order_locked: + processed = _process_success_order(order_locked, alipay_result.get('trade_no')) + if processed: + db.session.commit() + + return jsonify({ + 'code': 200, + 'msg': '订单已支付,积分已增加', + 'status': 'PAID', + 'points': order_locked.points, + 'paid_at': order_locked.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') + }) + else: + # 订单已经是完成状态,不做处理 + return jsonify({ + 'code': 200, + 'msg': '订单已支付', + 'status': 'PAID', + 'points': order_locked.points, + 'paid_at': order_locked.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if order_locked.paid_at else None + }) except Exception as e: if "LockError" in str(e) or "BlockingIOError" in str(e): # 如果获取锁失败,可能是因为正在处理中,返回成功状态(前端会重试或刷新) @@ -301,20 +335,10 @@ def payment_notify(): lock_key = f"lock:order:{out_trade_no}" with redis_client.lock(lock_key, timeout=10, blocking_timeout=3): order = Order.query.filter_by(out_trade_no=out_trade_no).with_for_update().first() - if order and order.status == 'PENDING': - order.status = 'PAID' - order.trade_no = trade_no - order.paid_at = get_bj_now() - - user = db.session.get(User, order.user_id) - if user: - user.points += order.points - system_logger.info(f"订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id) - + if order: + _process_success_order(order, trade_no) db.session.commit() return "success" - elif order: - return "success" else: return "fail" except Exception as e: @@ -326,6 +350,13 @@ def payment_notify(): # 如果返回fail,支付宝会重试。 return "fail" raise e + order = Order.query.filter_by(out_trade_no=out_trade_no).first() + if order: + _process_success_order(order, trade_no) + db.session.commit() + return "success" + else: + return "fail" else: return "fail" @@ -334,6 +365,7 @@ def payment_notify(): db.session.rollback() return "fail" + @payment_bp.route('/api/query/', methods=['GET']) def api_query_order(out_trade_no): """简单查询接口 - 获取订单当前状态而不自动更新""" diff --git a/models.py b/models.py index 2d76406..5e14be4 100644 --- a/models.py +++ b/models.py @@ -57,6 +57,10 @@ class User(db.Model): # 关联角色 ID role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) created_at = db.Column(db.DateTime, default=get_bj_now) + + # 邀请系统字段 + invite_code = db.Column(db.String(10), unique=True) # 用户专属邀请码 + invited_by = db.Column(db.Integer, db.ForeignKey('users.id')) # 邀请人ID @property def created_at_bj(self): @@ -203,3 +207,41 @@ class SavedPrompt(db.Model): return to_bj_time(self.created_at) user = db.relationship('User', backref=db.backref('saved_prompts', lazy='dynamic', order_by='SavedPrompt.created_at.desc()')) + +class PointsGrant(db.Model): + """积分发放记录(管理员手动发放)""" + __tablename__ = 'points_grants' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + points = db.Column(db.Integer, nullable=False) # 发放积分数 + reason = db.Column(db.String(200)) # 发放原因 + admin_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # 操作管理员 + created_at = db.Column(db.DateTime, default=get_bj_now) + + @property + def created_at_bj(self): + return to_bj_time(self.created_at) + + user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('received_grants', lazy='dynamic')) + admin = db.relationship('User', foreign_keys=[admin_id]) + +class InviteReward(db.Model): + """邀请奖励记录(被邀请人充值时,邀请人获得的奖励)""" + __tablename__ = 'invite_rewards' + + id = db.Column(db.Integer, primary_key=True) + inviter_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # 邀请人ID + invitee_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # 被邀请人ID + order_id = db.Column(db.Integer, db.ForeignKey('orders.id'), nullable=False) # 触发奖励的订单 + reward_points = db.Column(db.Integer, nullable=False) # 奖励积分数 + recharge_count = db.Column(db.Integer, nullable=False) # 这是被邀请人的第几次充值(1-3) + created_at = db.Column(db.DateTime, default=get_bj_now) + + @property + def created_at_bj(self): + return to_bj_time(self.created_at) + + inviter = db.relationship('User', foreign_keys=[inviter_id], backref=db.backref('invite_rewards', lazy='dynamic')) + invitee = db.relationship('User', foreign_keys=[invitee_id]) + order = db.relationship('Order', backref=db.backref('invite_reward', uselist=False)) diff --git a/scripts/reset_user.py b/scripts/reset_user.py new file mode 100644 index 0000000..2f81a0f --- /dev/null +++ b/scripts/reset_user.py @@ -0,0 +1,36 @@ +from app import create_app, db +from models import User, Order, InviteReward +import sys + +def reset_user(phone): + app = create_app() + with app.app_context(): + user = User.query.filter_by(phone=phone).first() + if not user: + print(f"❌ 找不到用户: {phone}") + return + + print(f"🔧 正在重置用户 {phone} (ID: {user.id})...") + + # 1. 删除该用户的订单 + orders = Order.query.filter_by(user_id=user.id).all() + order_count = len(orders) + for order in orders: + # 删除与该订单关联的邀请奖励(如果存在) + InviteReward.query.filter_by(order_id=order.id).delete() + db.session.delete(order) + + # 2. 重置用户积分(可选,方便测试) + # user.points = 2 # 重置为默认赠送积分 + + # 3. 删除该用户作为邀请人获得的奖励记录 (如果需要彻底重置) + # InviteReward.query.filter_by(inviter_id=user.id).delete() + + db.session.commit() + print(f"✅ 已删除 {order_count} 条订单记录,用户重置完成。可以重新测试邀请奖励。") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("使用方法: python scripts/reset_user.py <手机号>") + else: + reset_user(sys.argv[1]) diff --git a/static/js/auth.js b/static/js/auth.js index c6f32da..980e343 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -16,6 +16,7 @@ const updateUI = () => { const forgotBtn = getEl('forgotPwdBtn'); const smsGroup = getEl('smsGroup'); const captchaGroup = getEl('captchaGroup'); + const inviteGroup = getEl('inviteGroup'); if (!title || !sub || !submitBtnSpan || !switchBtn || !forgotBtn || !smsGroup || !captchaGroup) return; @@ -27,6 +28,7 @@ const updateUI = () => { forgotBtn.classList.add('hidden'); smsGroup.classList.remove('hidden'); captchaGroup.classList.remove('hidden'); // 注册模式默认显示图形验证码防止刷短信 + if (inviteGroup) inviteGroup.classList.remove('hidden'); } else if (authMode === 2) { // Reset Password title.innerText = "重置密码"; sub.innerText = "验证短信以设置新密码"; @@ -35,6 +37,7 @@ const updateUI = () => { forgotBtn.classList.add('hidden'); smsGroup.classList.remove('hidden'); captchaGroup.classList.remove('hidden'); // 重置模式默认显示 + if (inviteGroup) inviteGroup.classList.add('hidden'); } else { // Login title.innerText = "欢迎回来"; sub.innerText = "请登录以开启 AI 创作之旅"; @@ -43,13 +46,23 @@ const updateUI = () => { forgotBtn.classList.remove('hidden'); smsGroup.classList.add('hidden'); captchaGroup.classList.add('hidden'); // 登录默认隐藏,除非高频失败 + if (inviteGroup) inviteGroup.classList.add('hidden'); } }; const refreshCaptcha = () => { const phone = getEl('authPhone')?.value; const captchaImg = getEl('captchaImg'); - if (!phone || !captchaImg) return; + if (!captchaImg) return; + + if (!phone) { + // 如果没有手机号,不加载,或者显示占位(此处保持空,等待用户输入) + return; + } + + // 简单的手机号长度检查,避免无效请求 + if (phone.length < 11) return; + captchaImg.src = `/api/auth/captcha?phone=${phone}&t=${Date.now()}`; }; @@ -67,6 +80,7 @@ const handleAuth = async () => { if (authMode === 1) { url = '/api/auth/register'; body.code = getEl('authCode')?.value; + body.invite_code = getEl('authInvite')?.value; // 获取邀请码 } else if (authMode === 0) { url = '/api/auth/login'; if (!getEl('captchaGroup')?.classList.contains('hidden')) { @@ -149,6 +163,15 @@ document.addEventListener('DOMContentLoaded', () => { }); }); + // 监听手机号输入,自动刷新验证码 + const phoneInput = getEl('authPhone'); + if (phoneInput) { + phoneInput.addEventListener('blur', refreshCaptcha); + phoneInput.addEventListener('input', (e) => { + if (e.target.value.length === 11) refreshCaptcha(); + }); + } + getEl('sendSmsBtn').onclick = async () => { const phone = getEl('authPhone')?.value; const captcha = getEl('authCaptcha')?.value; @@ -199,4 +222,20 @@ document.addEventListener('DOMContentLoaded', () => { btn.disabled = false; } }; + + // 处理邀请链接逻辑 + const urlParams = new URLSearchParams(window.location.search); + const inviteCode = urlParams.get('invite_code'); + if (inviteCode) { + authMode = 1; // 切换到注册模式 + updateUI(); + refreshCaptcha(); // 注册需验证码 + const inviteInput = getEl('authInvite'); + if (inviteInput) { + inviteInput.value = inviteCode; + inviteInput.disabled = true; // 锁定邀请码 + inviteInput.classList.add('bg-indigo-50', 'text-indigo-600', 'font-bold'); + showToast('已自动填入邀请码', 'success'); + } + } }); diff --git a/static/js/main.js b/static/js/main.js index 83d0613..a9707cb 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -806,7 +806,7 @@ window.addEventListener('message', (e) => { } }); -// --- 积分与钱包中心逻辑 --- +// --- 积分与钱包中心逻辑 (Modern Modal Version) --- let pointsChart = null; async function openPointsModal() { @@ -818,11 +818,12 @@ async function openPointsModal() { modal.querySelector('div').classList.remove('scale-95'); }, 10); - // 加载数据 + // 加载核心数据 loadPointStats(); - loadPointDetails(); + loadPointDetails(1); + loadInviteStatsModal(); - // 更新当前余额 + // 实时更新余额显示 const r = await fetch('/api/auth/me'); const d = await r.json(); if (d.logged_in) { @@ -832,106 +833,180 @@ async function openPointsModal() { function closePointsModal() { const modal = document.getElementById('pointsModal'); + if (!modal) return; modal.classList.remove('opacity-100'); modal.querySelector('div').classList.add('scale-95'); setTimeout(() => modal.classList.add('hidden'), 300); } async function loadPointStats() { - const r = await fetch('/api/stats/points?days=7'); - const d = await r.json(); - - const canvas = document.getElementById('pointsChart'); - if (!canvas) return; - const ctx = canvas.getContext('2d'); - - if (pointsChart) pointsChart.destroy(); - - if (typeof Chart === 'undefined') { - console.error('Chart.js not loaded'); - return; - } - - pointsChart = new Chart(ctx, { - type: 'line', - data: { - labels: d.labels, - datasets: [ - { - label: '消耗积分', - data: d.deductions, - borderColor: '#6366f1', - backgroundColor: 'rgba(99, 102, 241, 0.1)', - borderWidth: 3, - - fill: true, - tension: 0.4, - pointRadius: 4, - pointBackgroundColor: '#6366f1' - }, - { - label: '充值积分', - data: d.incomes, - borderColor: '#10b981', - backgroundColor: 'rgba(16, 185, 129, 0.1)', - borderWidth: 3, - fill: true, - tension: 0.4, - pointRadius: 4, - pointBackgroundColor: '#10b981' - } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { display: false } - }, - scales: { - y: { - beginAtZero: true, - grid: { color: 'rgba(241, 245, 249, 1)' }, - ticks: { font: { weight: 'bold' } } - }, - x: { - grid: { display: false }, - ticks: { font: { weight: 'bold' } } - } - } - } - }); -} - -async function loadPointDetails() { - const body = document.getElementById('pointDetailsBody'); - if (!body) return; - body.innerHTML = '正在加载明细...'; - try { - const r = await fetch('/api/stats/details?page=1'); + const r = await fetch('/api/auth/point_stats'); const d = await r.json(); - if (d.items.length === 0) { - body.innerHTML = '暂无积分变动记录'; + const canvas = document.getElementById('modalTrendChart'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + if (pointsChart) pointsChart.destroy(); + + pointsChart = new Chart(ctx, { + type: 'line', + data: { + labels: d.labels, + datasets: [ + { + label: '消耗积分', + data: d.consumption, + borderColor: '#6366f1', + backgroundColor: 'rgba(99, 102, 241, 0.05)', + borderWidth: 3, + fill: true, + tension: 0.4, + pointRadius: 4, + pointBackgroundColor: '#6366f1' + }, + { + label: '充值积分', + data: d.recharge, + borderColor: '#10b981', + backgroundColor: 'rgba(16, 185, 129, 0.05)', + borderWidth: 3, + fill: true, + tension: 0.4, + pointRadius: 4, + pointBackgroundColor: '#10b981' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: '#1e293b', + padding: 12, + cornerRadius: 12, + titleFont: { weight: 'bold' }, + bodyFont: { weight: '900' } + } + }, + scales: { + y: { + beginAtZero: true, + grid: { color: '#f1f5f9' }, + ticks: { font: { weight: 'bold', size: 10 }, color: '#94a3b8' } + }, + x: { + grid: { display: false }, + ticks: { font: { weight: 'bold', size: 10 }, color: '#94a3b8' } + } + } + } + }); + } catch (e) { + console.error('加载统计图失败:', e); + } +} + +async function loadPointDetails(page = 1) { + const body = document.getElementById('pointDetailsBody'); + const pagContainer = document.getElementById('pointDetailsPagination'); + if (!body) return; + + body.innerHTML = '正在同步账户流水...'; + + try { + const r = await fetch(`/api/auth/point_history?page=${page}&per_page=5`); + const d = await r.json(); + + if (d.details.length === 0) { + body.innerHTML = '暂无资金变动记录'; + if (pagContainer) pagContainer.innerHTML = ''; return; } - body.innerHTML = d.items.map(item => ` - + body.innerHTML = d.details.map(item => { + const isAdd = item.amount.startsWith('+'); + const colorClass = isAdd ? 'text-emerald-500' : 'text-rose-500'; + const iconColor = isAdd ? 'bg-emerald-500' : 'bg-rose-500'; + + return ` + -
-
- ${item.desc} +
+
+
+ ${item.type_label} + ${item.desc} +
- ${item.model} - ${item.change} - ${item.time} + + + ${item.type === 'consumption' ? 'MODEL' : 'SYSTEM'} + + + + ${item.amount} + + ${item.time} - `).join(''); + `; + }).join(''); + + // 渲染分页 + if (pagContainer) { + if (d.pages > 1) { + let html = ''; + if (d.current_page > 1) { + html += ``; + } + html += `Page ${d.current_page} / ${d.pages}`; + if (d.current_page < d.pages) { + html += ``; + } + pagContainer.innerHTML = html; + } else { + pagContainer.innerHTML = ''; + } + } } catch (e) { - body.innerHTML = '加载失败'; + body.innerHTML = '流水加载异常'; + } +} + +async function loadInviteStatsModal() { + try { + const r = await fetch('/api/auth/invite_stats'); + const d = await r.json(); + if (d.invite_code) { + document.getElementById('modalInviteCode').innerText = d.invite_code; + document.getElementById('modalInvitedCount').innerText = d.invited_count; + document.getElementById('modalTotalRewards').innerText = d.total_rewards; + } + } catch (e) { console.error('加载邀请统计失败', e); } +} + +function copyInviteCodeModal() { + const code = document.getElementById('modalInviteCode').innerText; + if (code === '---') return; + const link = `${window.location.origin}/login?invite_code=${code}`; + navigator.clipboard.writeText(link).then(() => showToast('专属邀请链接已复制', 'success')); +} + +function scrollToModalRecharge() { + const area = document.getElementById('modalRechargeArea'); + if (area) area.scrollIntoView({ behavior: 'smooth' }); +} + +function submitModalRecharge(pkgId) { + const form = document.getElementById('modalRechargeForm'); + const input = document.getElementById('modalPackageId'); + if (form && input) { + input.value = pkgId; + form.submit(); } } diff --git a/templates/buy.html b/templates/buy.html index b3899d5..99ff7c4 100644 --- a/templates/buy.html +++ b/templates/buy.html @@ -1,322 +1,284 @@ {% extends "base.html" %} -{% block title %}购买积分 - AI 视界{% endblock %} +{% block title %}充值与邀请 - AI 视界{% endblock %} {% block content %} -
-
-
-
- -
-
-

充值积分

-

选择适合您的积分套餐,开启无限创作空间

-
-
+
+
- {% if success %} -
-
- -
-
-

支付成功!

-

您的订单 #{{ order.out_trade_no if order else '' }} 已完成,积分已到账。 -

-
-
- {% endif %} - -
-
- -
-
体验测试
-
- 50 - 积分 -
-
-
- - 快速开启 AI 体验 -
-
-
- - -
+ +
+
+
+
- - -
-
- 推荐
-
普通创作
-
- 200 - 积分 -
-
-
- - 满足日常生成需求 -
-
-
- - -
-
- - -
-
量大管饱
-
- 1000 - 积分 -
-
-
- - 创作自由,无需等待 -
-
-
- - -
-
- - -
-
豪掷千金
-
- 5000 - 积分 -
-
-
- - 至尊权限,顶级服务 -
-
-
- - -
+
+

充值中心

+

Recharge & Invite +

-
- - -
-

- - 支付安全保障 · 充值实时到账 -

-
- -
- -
- +
{% if is_admin %} - + + + 管理后台 + {% endif %} +
+
- -
-
- - - - - - - - - - - {% if personal_orders %} - {% for order in personal_orders %} - - - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
- 订单号 - 积分/金额状态 - - 支付时间
-
- {{ order.out_trade_no }} - Ali: {{ order.trade_no or '-' - }} -
-
-
-
- - +{{ order.points }} -
- ¥{{ order.amount }} -
-
- {% if order.status == 'PAID' %} - 已完成 - {% elif order.status == 'PENDING' %} - 待支付 - {% else %} - 已取消 - {% endif %} - - - {% if order.paid_at_bj %} - {{ order.paid_at_bj.strftime('%m-%d %H:%M') }} - {% else %} - - - {% endif %} - -
-

暂无记录

-
-
-
- - 查看更多记录 - + +
+
+

选择充值套餐

+
+
+ 即时到账
- {% if is_admin %} - - {% endblock %} {% block scripts %} {% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 08a0638..1d1770c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -275,194 +275,209 @@
- +