返回 AiToEarn
auth.controller.ts
根目录 / project / aitoearn-backend / apps / aitoearn-server / src / core / channels / auth / auth.controller.ts
1 import type { Request, Response } from 'express'
2 import { Body, Controller, Get, Headers, Logger, Param, Post, Query, Req, Res } from '@nestjs/common'
3 import { ApiTags } from '@nestjs/swagger'
4 import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth'
5 import { AccountType, ApiDoc, AppException, CookieName, getCodeMessage, getExceptionPayload, getLocale, ResponseCode } from '@yikart/common'
6 import { AuthCallbackResponseType } from '../platforms/platforms.interface'
7 import { getAuthCallbackState } from '../utils/auth-callback-state.util'
8 import {
9 authViewMessages,
10 clearChannelAuthSessionCookie,
11 setChannelAuthSessionCookie,
12 } from '../utils/auth.utils'
13 import {
14 AuthCallbackBodyDto,
15 AuthCallbackQueryDto,
16 StartAuthQueryDto,
17 SubmitAuthSelectionsDto,
18 } from './auth.dto'
19 import { AuthService } from './auth.service'
20 import { AuthConnectedAccountVoSchema, AuthSelectableAccountVoSchema, AuthSessionStatusVo, AuthStartVo } from './auth.vo'
21
22 @ApiTags('Channels/Auth')
23 @Controller({ path: '/channels', version: '2' })
24 export class AuthController {
25 private readonly logger = new Logger(AuthController.name)
26
27 constructor(
28 private readonly authService: AuthService,
29 ) {}
30
31 @ApiDoc({
32 summary: '开始平台授权',
33 description: '生成平台 OAuth 授权 URL',
34 query: StartAuthQueryDto.schema,
35 response: AuthStartVo,
36 })
37 @Get('/accounts/auth/:platform')
38 async startAuth(
39 @GetToken() token: TokenInfo,
40 @Param('platform') platform: AccountType,
41 @Query() query: StartAuthQueryDto,
42 @Headers('user-agent') userAgent: string | undefined,
43 @Res({ passthrough: true }) res: Response,
44 ) {
45 const result = await this.authService.startAuth({
46 userId: token.id,
47 platform,
48 callbackUrl: query.callbackUrl,
49 redirectUri: query.redirectUri,
50 groupId: query.groupId,
51 userAgent,
52 })
53 setChannelAuthSessionCookie(res, result.sessionId, result.expiresAt)
54 return AuthStartVo.create(result)
55 }
56
57 @ApiDoc({
58 summary: '查询授权状态',
59 description: '轮询平台授权 Session 状态',
60 response: AuthSessionStatusVo,
61 })
62 @Get('/accounts/auth/:platform/status/:sessionId')
63 async getAuthStatus(
64 @GetToken() token: TokenInfo,
65 @Param('platform') platform: AccountType,
66 @Param('sessionId') sessionId: string,
67 ) {
68 const result = await this.authService.getAuthSessionResult(token.id, platform, sessionId)
69 return AuthSessionStatusVo.create(result)
70 }
71
72 @ApiDoc({
73 summary: '平台授权回调',
74 description: '处理平台 OAuth callback (GET)',
75 query: AuthCallbackQueryDto.schema,
76 })
77 @Public()
78 @Get('/accounts/auth/:platform/callback')
79 async handleCallbackGet(
80 @Param('platform') platform: AccountType,
81 @Query() query: AuthCallbackQueryDto,
82 @Req() req: Request,
83 @Res() res: Response,
84 ) {
85 let sessionId: string | undefined
86 try {
87 sessionId = this.getCallbackSessionId(req, { query })
88 const result = await this.authService.completeCallback(
89 platform,
90 { query },
91 sessionId,
92 )
93 return this.renderCallbackResult(res, result)
94 }
95 catch (error) {
96 const callbackSessionId = sessionId ?? this.getOptionalCallbackSessionId(req, { query }) ?? 'unknown'
97 await this.markCallbackSessionFailed(sessionId ?? this.getOptionalCallbackSessionId(req, { query }), this.getErrorCode(error))
98 this.logger.error(error, `Channel auth callback failed: platform=${platform}, sessionId=${callbackSessionId}`)
99 return this.renderCallbackError(res, error, platform)
100 }
101 }
102
103 @ApiDoc({
104 summary: '平台授权回调 (POST)',
105 description: '处理平台 OAuth callback POST 请求',
106 query: AuthCallbackQueryDto.schema,
107 body: AuthCallbackBodyDto.schema,
108 })
109 @Public()
110 @Post('/accounts/auth/:platform/callback')
111 async handleCallbackPost(
112 @Param('platform') platform: AccountType,
113 @Body() body: AuthCallbackBodyDto,
114 @Query() query: AuthCallbackQueryDto,
115 @Req() req: Request,
116 @Res() res: Response,
117 ) {
118 let sessionId: string | undefined
119 try {
120 sessionId = this.getCallbackSessionId(req, { query, body })
121 const result = await this.authService.completeCallback(
122 platform,
123 { query, body },
124 sessionId,
125 )
126 return this.renderCallbackResult(res, result)
127 }
128 catch (error) {
129 await this.markCallbackSessionFailed(sessionId ?? this.getOptionalCallbackSessionId(req, { query, body }), this.getErrorCode(error))
130 throw error
131 }
132 }
133
134 private renderCallbackResult(res: Response, result: Awaited<ReturnType<AuthService['completeCallback']>>) {
135 if (result.callbackResponseType === AuthCallbackResponseType.Json) {
136 const accounts = result.connectedAccounts
137 ? AuthConnectedAccountVoSchema.array().parse(result.connectedAccounts)
138 : AuthSelectableAccountVoSchema.array().parse(result.accounts ?? [])
139 clearChannelAuthSessionCookie(res)
140 res.setHeader('Cache-Control', 'no-store')
141 res.setHeader('Pragma', 'no-cache')
142 return res.json({
143 status: result.requiresSelection ? 0 : 1,
144 accountId: result.accountId,
145 accounts,
146 requiresSelection: Boolean(result.requiresSelection),
147 })
148 }
149
150 const locale = getLocale()
151 if (result.requiresSelection) {
152 return res.render('channels/auth/select-accounts', {
153 ...result,
154 locale,
155 messages: authViewMessages[locale],
156 })
157 }
158
159 clearChannelAuthSessionCookie(res)
160 return res.render('channels/auth/callback', {
161 status: 1,
162 accountId: result.accountId,
163 accounts: result.connectedAccounts ?? [],
164 locale,
165 platformDisplayName: result.platformDisplayName,
166 platformLogoUrl: result.platformLogoUrl,
167 messages: authViewMessages[locale],
168 callbackUrl: result.callbackUrl,
169 redirectUri: result.redirectUri,
170 })
171 }
172
173 @ApiDoc({
174 summary: '提交二级账号选择',
175 description: '用户选择要绑定的二级账号',
176 body: SubmitAuthSelectionsDto.schema,
177 })
178 @Public()
179 @Post('/accounts/auth/selections')
180 async submitSelections(
181 @Body() body: SubmitAuthSelectionsDto,
182 @Req() req: Request,
183 @Res() res: Response,
184 ) {
185 const sessionId = this.getAuthSessionId(req)
186 const result = await this.authService.connectSelectableAccounts(
187 sessionId,
188 body.accounts,
189 )
190 clearChannelAuthSessionCookie(res)
191 const locale = getLocale()
192 return res.render('channels/auth/callback', {
193 status: 1,
194 accountId: result.accountIds[0],
195 accounts: result.accounts,
196 locale,
197 platformDisplayName: result.platformDisplayName,
198 platformLogoUrl: result.platformLogoUrl,
199 messages: authViewMessages[locale],
200 callbackUrl: result.callbackUrl,
201 redirectUri: result.redirectUri,
202 })
203 }
204
205 private getAuthSessionId(
206 req: Request,
207 ): string {
208 const sessionId = req.cookies?.[CookieName.ChannelAuthSession]
209
210 if (sessionId) {
211 return sessionId
212 }
213 throw new AppException(ResponseCode.ChannelAuthSessionInvalid)
214 }
215
216 private getCallbackSessionId(
217 req: Request,
218 callbackInput: { query?: AuthCallbackQueryDto, body?: AuthCallbackBodyDto },
219 ): string {
220 const sessionId = this.getOptionalCallbackSessionId(req, callbackInput)
221 if (sessionId) {
222 return sessionId
223 }
224
225 throw new AppException(ResponseCode.ChannelAuthSessionInvalid)
226 }
227
228 private getOptionalCallbackSessionId(
229 req: Request,
230 callbackInput: { query?: AuthCallbackQueryDto, body?: AuthCallbackBodyDto },
231 ): string | undefined {
232 return getAuthCallbackState(callbackInput) ?? req.cookies?.[CookieName.ChannelAuthSession]
233 }
234
235 private renderCallbackError(res: Response, error: unknown, platform: AccountType) {
236 clearChannelAuthSessionCookie(res)
237 const locale = getLocale()
238 const payload = getExceptionPayload(error)
239 const errorCode = this.getErrorCode(error)
240 const errorMessage = this.isResponseCode(errorCode)
241 ? getCodeMessage(errorCode, payload.data, locale)
242 : payload.message
243 const platformViewFields = this.getPlatformErrorViewFields(platform)
244
245 return res.render('channels/auth/error', {
246 locale,
247 messages: authViewMessages[locale],
248 ...platformViewFields,
249 errorCode,
250 errorMessage,
251 })
252 }
253
254 private async markCallbackSessionFailed(sessionId: string | undefined, errorCode: number): Promise<void> {
255 if (!sessionId) {
256 return
257 }
258 await this.authService.markSessionFailed(sessionId, errorCode)
259 }
260
261 private getPlatformErrorViewFields(platform: AccountType) {
262 try {
263 return this.authService.getPlatformAuthViewFields(platform)
264 }
265 catch {
266 return {
267 platformDisplayName: String(platform),
268 platformLogoUrl: undefined,
269 }
270 }
271 }
272
273 private isResponseCode(code: number): code is ResponseCode {
274 return Object.hasOwn(ResponseCode, code)
275 }
276
277 private getErrorCode(error: unknown): number {
278 const payload = getExceptionPayload(error)
279 return typeof payload.code === 'number'
280 ? payload.code
281 : 500
282 }
283 }
284
284 lines TYPESCRIPT