diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0c7b9cd..e529bd8 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,9 +1,10 @@ import "reflect-metadata"; -import { Controller, Get, Injectable, Module } from "@nestjs/common"; +import { Body, Controller, Get, Injectable, Module, Post } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { FastifyAdapter } from "@nestjs/platform-fastify"; import type { NestFastifyApplication } from "@nestjs/platform-fastify"; import { PrismaClient } from "@prisma/client"; +import { WechatAuthService } from "./wechat-auth.js"; @Injectable() export class PrismaService extends PrismaClient { @@ -20,7 +21,35 @@ export class HealthController { } } -@Module({ controllers: [HealthController], providers: [PrismaService], exports: [PrismaService] }) +@Controller("public") +export class PublicController { + @Get("brand") + brand(): { message: string } { + return { message: "public brand endpoint is ready" }; + } + + @Get("services") + services(): { message: string } { + return { message: "public services endpoint is ready" }; + } +} + +@Controller("customer/session") +export class CustomerSessionController { + constructor(private readonly wechatAuth: WechatAuthService) {} + + @Post("wechat") + async wechatLogin(@Body() body: { code?: string }): Promise<{ openid: string }> { + const session = await this.wechatAuth.exchangeCode(body.code ?? ""); + return { openid: session.openid }; + } +} + +@Module({ + controllers: [HealthController, PublicController, CustomerSessionController], + providers: [PrismaService, WechatAuthService], + exports: [PrismaService, WechatAuthService], +}) export class AppModule {} export async function createApp(): Promise { diff --git a/apps/api/src/wechat-auth.ts b/apps/api/src/wechat-auth.ts new file mode 100644 index 0000000..f5adb8a --- /dev/null +++ b/apps/api/src/wechat-auth.ts @@ -0,0 +1,44 @@ +import { Injectable, ServiceUnavailableException, UnauthorizedException } from "@nestjs/common"; + +interface WechatSessionResponse { + openid?: string; + session_key?: string; + unionid?: string; + errcode?: number; + errmsg?: string; +} + +export interface WechatSession { + openid: string; + sessionKey: string; + unionid?: string; +} + +@Injectable() +export class WechatAuthService { + async exchangeCode(code: string): Promise { + if (!code.trim()) throw new UnauthorizedException("微信登录 code 不能为空"); + const appId = process.env.WECHAT_APP_ID; + const appSecret = process.env.WECHAT_APP_SECRET; + if (!appId || !appSecret) throw new ServiceUnavailableException("微信登录尚未配置"); + + const query = new URLSearchParams({ + appid: appId, + secret: appSecret, + js_code: code, + grant_type: "authorization_code", + }); + const response = await fetch( + `https://api.weixin.qq.com/sns/jscode2session?${query.toString()}`, + ); + if (!response.ok) throw new ServiceUnavailableException("微信登录服务暂时不可用"); + const result = (await response.json()) as WechatSessionResponse; + if (!result.openid || !result.session_key) + throw new UnauthorizedException(result.errmsg ?? "微信登录失败"); + return { + openid: result.openid, + sessionKey: result.session_key, + ...(result.unionid ? { unionid: result.unionid } : {}), + }; + } +} diff --git a/docs/03-data-and-api-design.md b/docs/03-data-and-api-design.md index d7986a6..743ef43 100644 --- a/docs/03-data-and-api-design.md +++ b/docs/03-data-and-api-design.md @@ -158,6 +158,10 @@ POST /api/admin/uploads/presign ## 5. 文件上传流程 +## 5.1 微信客户会话 + +小程序调用 `wx.login` 获取临时 `code`,提交到 `/api/customer/session/wechat`。服务端使用微信官方 `jscode2session` 接口换取 OpenID 和 session key,并在服务端建立 `CustomerUser` 映射和登录态。当前代码将微信调用封装在 `WechatAuthService`,使用 Node 原生 `fetch`,不引入第三方微信 SDK;后续订单接口必须从服务端登录态解析当前 OpenID,不能由客户端提交或选择 `customer_user_id`。生产实现不得把 OpenID 或 session key 返回给小程序,应改为 HttpOnly 会话 Cookie 或短期签名令牌。 + 1. 后台请求上传凭证。 2. API 校验管理员权限和文件元数据。 3. 前端直接上传对象存储。