Next.js从入门到实战保姆级教程(第十二章):认证鉴权与中间件

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

认证(Authentication) 解决"你是谁"的问题,鉴权(Authorization) 解决"你能做什么"的问题。这两者构成了应用安全的基石,实施不当将导致严重的安全漏洞。

一、认证方案选型

在 Next.js 生态中实现认证,主要有三种技术路线:

graph TD A[需要认证功能] --> B{项目复杂度与需求} B -->|快速开发
标准需求| C[Auth.js
原NextAuth.js] B -->|高度定制
企业内建| D[手动实现 JWT] B -->|托管服务
全功能| E[Clerk / Supabase Auth] C --> F[支持OAuth社交登录
内置Session管理
App Router深度集成] D --> G[完全自定义控制
需编写更多代码
自行处理安全细节] E --> H[ 功能最全面
零维护成本
产生服务费用
厂商锁定风险]

方案选择建议

场景 推荐方案 理由
初创项目/MVP Auth.js 快速搭建,社区活跃
企业内部系统 手动JWT 已有认证基础设施
SaaS产品 Clerk/Supabase 节省开发时间,专注业务
高安全要求 手动JWT + 审计 完全掌控安全策略

对于大多数应用场景,Auth.js(原 NextAuth.js v5) 是最佳选择:支持数十种 OAuth 提供商(Google、GitHub、微信等),内置 Session 管理机制,与 Next.js App Router 深度集成。

本章重点讲解 Auth.js 方案,同时剖析 JWT 手动实现的底层原理。


二、Auth.js 完整认证实现

1. 安装与基础配置

bash 复制代码
npm install next-auth@beta

(1)实例化Next-Auth

typescript 复制代码
// auth.ts(项目根目录)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import type { DefaultSession } from 'next-auth';

// 扩展 Session 类型
declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: string;
    } & DefaultSession['user'];
  }
}

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    // GitHub OAuth 登录
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),

    // Google OAuth 登录
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),

    // 邮箱密码登录(Credentials Provider)
    Credentials({
      name: '邮箱密码',
      credentials: {
        email: { label: '邮箱', type: 'email', placeholder: 'example@email.com' },
        password: { label: '密码', type: 'password' },
      },
      async authorize(credentials) {
        // 参数验证
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        // 查询用户
        const user = await db.users.findUnique({
          where: { email: credentials.email as string },
        });

        // 用户不存在或无密码
        if (!user || !user.hashedPassword) {
          return null;
        }

        // 验证密码
        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.hashedPassword
        );

        if (!isValid) {
          return null;
        }

        // 返回用户信息(敏感信息不返回)
        return {
          id: user.id,
          name: user.name,
          email: user.email,
          image: user.avatar,
          role: user.role,
        };
      },
    }),
  ],

  // Session 配置
  session: {
    strategy: 'jwt',  // 使用 JWT,无需数据库存储 Session
    maxAge: 30 * 24 * 60 * 60,  // 30 天过期
  },

  // 自定义页面路径
  pages: {
    signIn: '/login',      // 自定义登录页
    signOut: '/logout',    // 自定义登出页
    error: '/auth/error',  // 错误页面
  },

  // Callbacks:自定义认证流程
  callbacks: {
    /**
     * JWT Token 回调
     * 在 token 创建/更新时触发
     */
    async jwt({ token, user }) {
      // 首次登录时,将用户信息添加到 token
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },

    /**
     * Session 回调
     * 每次读取 session 时触发
     */
    async session({ session, token }) {
      if (token && session.user) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },
  },

  // 调试模式(仅开发环境)
  debug: process.env.NODE_ENV === 'development',
});

(2)定义route handler

typescript 复制代码
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

// 导出 GET 和 POST 处理器
export const { GET, POST } = handlers;

(3)填写配置信息

bash 复制代码
# .env.local
AUTH_SECRET="your-secret-key-min-32-chars"  # 使用 openssl rand -base64 32 生成
GITHUB_ID="your-github-app-id"
GITHUB_SECRET="your-github-app-secret"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

安全提示AUTH_SECRET 必须至少 32 个字符,生产环境务必使用强随机字符串。

2. 登录页面实现

ts 复制代码
// app/login/page.tsx
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
import { redirect } from 'next/navigation';
import Link from 'next/link';

interface LoginPageProps {
  searchParams: Promise<{ callbackUrl?: string; error?: string }>;
}

export default async function LoginPage({ 
  searchParams 
}: LoginPageProps) {
  const { callbackUrl, error } = await searchParams;

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
      <div className="w-full max-w-md p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
        <h1 className="text-2xl font-bold text-center mb-8 text-gray-900">
          欢迎登录
        </h1>

        {/* 错误提示 */}
        {error && (
          <div 
            className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm border border-red-200"
            role="alert"
          >
            {error === 'CredentialsSignin'
              ? '邮箱或密码错误,请重试'
              : '登录失败,请稍后重试'}
          </div>
        )}

        {/* 邮箱密码登录表单 */}
        <form
          action={async (formData) => {
            'use server';
            try {
              await signIn('credentials', {
                email: formData.get('email'),
                password: formData.get('password'),
                redirectTo: callbackUrl || '/dashboard',
              });
            } catch (error) {
              if (error instanceof AuthError) {
                redirect(`/login?error=${error.type}`);
              }
              throw error;
            }
          }}
          className="space-y-4"
        >
          <div>
            <label 
              htmlFor="email" 
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              邮箱地址
            </label>
            <input
              id="email"
              name="email"
              type="email"
              required
              autoComplete="email"
              className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              placeholder="your@email.com"
            />
          </div>

          <div>
            <label 
              htmlFor="password" 
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              密码
            </label>
            <input
              id="password"
              name="password"
              type="password"
              required
              autoComplete="current-password"
              className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              placeholder="••••••••"
            />
          </div>

          <button
            type="submit"
            className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
          >
            登录
          </button>
        </form>

        {/* 分隔线 */}
        <div className="my-6 flex items-center gap-4">
          <hr className="flex-1 border-gray-300" />
          <span className="text-gray-400 text-sm">或使用以下方式登录</span>
          <hr className="flex-1 border-gray-300" />
        </div>

        {/* OAuth 登录按钮 */}
        <div className="space-y-3">
          {/* GitHub 登录 */}
          <form
            action={async () => {
              'use server';
              await signIn('github', {
                redirectTo: callbackUrl || '/dashboard',
              });
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-2 border border-gray-300 py-2 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
                <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
              </svg>
              使用 GitHub 登录
            </button>
          </form>

          {/* Google 登录 */}
          <form
            action={async () => {
              'use server';
              await signIn('google', {
                redirectTo: callbackUrl || '/dashboard',
              });
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-2 border border-gray-300 py-2 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" viewBox="0 0 24 24">
                <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
                <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
                <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
                <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
              </svg>
              使用 Google 登录
            </button>
          </form>
        </div>

        {/* 注册链接 */}
        <p className="mt-6 text-center text-sm text-gray-600">
          还没有账号?{' '}
          <Link 
            href="/register" 
            className="text-blue-600 hover:text-blue-700 font-medium"
          >
            立即注册
          </Link>
        </p>
      </div>
    </div>
  );
}

三、Session 获取与管理

Auth.js 提供了多种场景下的 Session 获取方式:

1. 服务端组件中获取

ts 复制代码
// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  // 获取当前会话
  const session = await auth();

  // 未登录则重定向
  if (!session?.user) {
    redirect('/login?callbackUrl=/dashboard');
  }

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-2xl font-bold mb-4">
        欢迎,{session.user.name}
      </h1>
      <p className="text-gray-600">邮箱:{session.user.email}</p>
      <p className="text-gray-600">角色:{session.user.role}</p>
    </div>
  );
}

2. 客户端组件中获取

ts 复制代码
// components/UserMenu.tsx
'use client';

import { useSession, signOut } from 'next-auth/react';
import Image from 'next/image';
import Link from 'next/link';

export function UserMenu() {
  const { data: session, status } = useSession();

  // 加载中状态
  if (status === 'loading') {
    return (
      <div className="w-8 h-8 rounded-full bg-gray-200 animate-pulse" />
    );
  }

  // 未登录
  if (!session) {
    return (
      <Link 
        href="/login"
        className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
      >
        登录
      </Link>
    );
  }

  // 已登录
  return (
    <div className="flex items-center gap-3">
      {session.user.image && (
        <Image
          src={session.user.image}
          alt={`${session.user.name} 的头像`}
          width={32}
          height={32}
          className="rounded-full border border-gray-200"
        />
      )}
      <span className="text-sm text-gray-700">{session.user.name}</span>
      <button
        onClick={() => signOut({ callbackUrl: '/' })}
        className="text-sm text-gray-500 hover:text-red-600 transition-colors"
      >
        退出
      </button>
    </div>
  );
}

需在根布局中包裹 SessionProvider

ts 复制代码
// app/layout.tsx
import { SessionProvider } from 'next-auth/react';
import { auth } from '@/auth';
import type { ReactNode } from 'react';

interface RootLayoutProps {
  children: ReactNode;
}

export default async function RootLayout({ 
  children 
}: RootLayoutProps) {
  const session = await auth();

  return (
    <html lang="zh-CN">
      <body>
        <SessionProvider session={session}>
          {children}
        </SessionProvider>
      </body>
    </html>
  );
}

四、Middleware:路由级认证保护

中间件(Middleware )运行在 Edge Runtime ,在请求到达路由处理程序之前执行。这是实现路由级别认证保护的最佳位置------比在每个页面单独检查 Session 高效得多。

1. 基础路由保护

typescript 复制代码
// middleware.ts(项目根目录)
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export default auth((req: NextRequest) => {
  const { nextUrl } = req;
  const session = req.auth;
  const isLoggedIn = !!session?.user;

  // 定义受保护路由
  const protectedRoutes = [
    '/dashboard',
    '/profile',
    '/settings',
    '/admin',
  ];

  const isProtectedRoute = protectedRoutes.some(route => 
    nextUrl.pathname.startsWith(route)
  );

  // 未登录访问受保护页面 → 重定向到登录页
  if (isProtectedRoute && !isLoggedIn) {
    const redirectUrl = new URL('/login', nextUrl);
    redirectUrl.searchParams.set('callbackUrl', nextUrl.pathname);
    return NextResponse.redirect(redirectUrl);
  }

  // 已登录访问登录/注册页 → 重定向到仪表板
  if (isLoggedIn && ['/login', '/register'].includes(nextUrl.pathname)) {
    return NextResponse.redirect(new URL('/dashboard', nextUrl));
  }

  return NextResponse.next();
});

// 配置中间件匹配规则(排除静态资源)
export const config = {
  matcher: [
    /*
     * 匹配所有路径,除了:
     * - api(API 路由)
     * - _next/static(静态文件)
     * - _next/image(图片优化)
     * - favicon.ico(网站图标)
     * - public 文件夹
     */
    '/((?!api|_next/static|_next/image|favicon.ico|public).*)',
  ],
};

2. 基于角色的访问控制(RBAC)

typescript 复制代码
// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export default auth((req: NextRequest) => {
  const { nextUrl } = req;
  const session = req.auth;
  const userRole = session?.user?.role;

  // 管理员路由保护
  if (nextUrl.pathname.startsWith('/admin')) {
    // 未登录
    if (!session?.user) {
      return NextResponse.redirect(new URL('/login', nextUrl));
    }
    
    // 非管理员
    if (userRole !== 'admin') {
      return NextResponse.redirect(new URL('/403', nextUrl));
    }
  }

  // 编辑者路由保护
  if (nextUrl.pathname.startsWith('/editor')) {
    if (!session?.user) {
      return NextResponse.redirect(new URL('/login', nextUrl));
    }
    
    const allowedRoles = ['admin', 'editor'];
    if (!allowedRoles.includes(userRole || '')) {
      return NextResponse.redirect(new URL('/403', nextUrl));
    }
  }

  return NextResponse.next();
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

五、手动实现 JWT 认证(深入理解)

虽然推荐使用 Auth.js,但理解 JWT 认证的底层原理对应对复杂场景至关重要。许多企业内部系统已有后端 API 提供 JWT,前端只需处理 Token 的存储和传递。

1. JWT 工作流程

sequenceDiagram participant User as 用户 participant Client as 客户端 participant Server as 服务端 participant DB as 数据库 User->>Client: 提交邮箱密码 Client->>Server: POST /api/login Server->>DB: 验证用户凭证 DB-->>Server: 返回用户信息 Server->>Server: 生成 JWT Token Server-->>Client: 返回 JWT Client->>Client: 存储于 HttpOnly Cookie User->>Client: 访问受保护页面 Client->>Server: 携带 JWT Cookie Server->>Server: 验证 JWT 有效性 Server-->>Client: 返回数据

2. 安全 Token 管理

一般可以把Token存在HTTPOnly Cookie里。

typescript 复制代码
// lib/session.ts
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';

// JWT 密钥(生产环境从环境变量读取)
const SECRET_KEY = new TextEncoder().encode(
  process.env.JWT_SECRET || 'fallback-secret-change-in-production'
);

/**
 * 创建 Session Token
 * @param userId - 用户ID
 * @param role - 用户角色
 */
export async function createSession(
  userId: string, 
  role: string
): Promise<void> {
  const token = await new SignJWT({ userId, role })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')  // 7 天过期
    .sign(SECRET_KEY);

  const cookieStore = await cookies();

  // 将 JWT 存储在 HttpOnly Cookie 中
  cookieStore.set('session', token, {
    httpOnly: true,           // JavaScript 无法读取,防止 XSS
    secure: process.env.NODE_ENV === 'production',  // 仅 HTTPS
    sameSite: 'lax',          // 防止 CSRF
    maxAge: 7 * 24 * 60 * 60, // 7 天
    path: '/',
  });
}

/**
 * 验证并读取 Session
 * @returns Session 载荷或 null
 */
export async function getSession(): Promise<{ 
  userId: string; 
  role: string 
} | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get('session')?.value;

  if (!token) {
    return null;
  }

  try {
    const { payload } = await jwtVerify(token, SECRET_KEY);
    return payload as { userId: string; role: string };
  } catch (error) {
    // Token 无效或已过期
    console.error('Invalid token:', error);
    return null;
  }
}

/**
 * 删除 Session(退出登录)
 */
export async function deleteSession(): Promise<void> {
  const cookieStore = await cookies();
  cookieStore.delete('session');
}

为什么使用 Cookie 而非 localStorage?

localStorage 可被 JavaScript 访问,易受 XSS 攻击(恶意脚本窃取 Token)。HttpOnly Cookie 无法被 JavaScript 访问,XSS 攻击无法窃取 Token。这是 Web 安全的核心实践之一。


六、安全防护最佳实践

1. 敏感信息绝不暴露于客户端

typescript 复制代码
// ❌ 危险:在客户端存储权限信息
localStorage.setItem('isAdmin', 'true');

// ✅ 正确:仅在服务端验证权限
export async function DELETE(request: Request) {
  const session = await getSession();
  const user = await db.users.findUnique({ 
    where: { id: session?.userId } 
  });

  if (user?.role !== 'admin') {
    return new Response('Forbidden', { status: 403 });
  }

  await deleteData();
}

2. 密码哈希存储

typescript 复制代码
import bcrypt from 'bcryptjs';

// 注册时:哈希密码
const saltRounds = 12;  // 10-12 为合理范围
const hashedPassword = await bcrypt.hash(plainPassword, saltRounds);
await db.users.create({ 
  data: { email, hashedPassword } 
});

// 登录时:验证密码
const isValid = await bcrypt.compare(plainPassword, hashedPassword);
if (!isValid) {
  throw new Error('密码错误');
}

绝对禁止明文存储密码,即使数据库泄露,哈希密码也能保护用户安全。

3. 限制登录尝试次数

typescript 复制代码
// lib/rate-limit.ts
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const MAX_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60; // 15 分钟

/**
 * 检查登录速率限制
 */
export async function checkRateLimit(email: string): Promise<{
  allowed: boolean;
  remainingAttempts?: number;
  lockoutUntil?: Date;
}> {
  const key = `login_attempts:${email}`;
  const attempts = await redis.get<number>(key) || 0;

  if (attempts >= MAX_ATTEMPTS) {
    const ttl = await redis.ttl(key);
    return {
      allowed: false,
      lockoutUntil: new Date(Date.now() + ttl * 1000),
    };
  }

  return {
    allowed: true,
    remainingAttempts: MAX_ATTEMPTS - attempts,
  };
}

/**
 * 记录登录失败
 */
export async function recordFailedAttempt(email: string): Promise<void> {
  const key = `login_attempts:${email}`;
  await redis.incr(key);
  await redis.expire(key, LOCKOUT_DURATION);
}

/**
 * 重置登录尝试计数
 */
export async function resetLoginAttempts(email: string): Promise<void> {
  const key = `login_attempts:${email}`;
  await redis.del(key);
}

4. CSRF 防护

Auth.js 内置 CSRF 保护。若手动实现 JWT,需添加 CSRF Token:

typescript 复制代码
// 生成 CSRF Token
import { randomBytes } from 'crypto';

export function generateCsrfToken(): string {
  return randomBytes(32).toString('hex');
}

// 验证 CSRF Token
export function verifyCsrfToken(
  tokenFromCookie: string,
  tokenFromBody: string
): boolean {
  return tokenFromCookie === tokenFromBody;
}

七、本章小结

通过本章学习,你应该掌握了:

  • Auth.js 的完整配置与使用方法
  • OAuth 社交登录与邮箱密码登录的实现
  • Session 在服务端和客户端组件中的获取方式
  • Middleware 路由保护与 RBAC 权限控制
  • JWT 手动实现的底层原理与安全存储
  • 常见安全陷阱及防护策略(XSS、CSRF、暴力破解)

下一章《从原理到实践深度剖析缓存策略》将继续更深入地剖析 Next.js 的多层缓存架构,揭示其工作原理、最佳实践以及常见陷阱。

相关推荐
energy_DT2 小时前
2026年十五五油气田智能增产装备数字孪生,CIMPro孪大师赋能“流动增产工厂”三维可视化管控
前端
龙猫里的小梅啊2 小时前
CSS(四)CSS文本属性
前端·css
MXN_小南学前端2 小时前
watch详解:与computed 对比以及 Vue2 / Vue3 区别
前端·javascript·vue.js
饭小猿人2 小时前
Flutter实现底部动画弹窗有两种方式
开发语言·前端·flutter
让学习成为一种生活方式2 小时前
pbtk v 3.5.0安装与使用--生信工具084
前端·chrome
heimeiyingwang2 小时前
【架构实战】FinOps云成本优化实践
前端·chrome·架构
Mr Xu_3 小时前
从后端数据到前端图表:深入解析 reduce 与 flatMap 的数据整形实战
前端·javascript
玖玖passion3 小时前
Windows 上部署 Hermes Agent 完整指南 - 让你的 AI 助手在 WSL2 中跑起来
前端·后端·github