diff --git a/it0-web-admin/package-lock.json b/it0-web-admin/package-lock.json index 82426f1..f0a73ce 100644 --- a/it0-web-admin/package-lock.json +++ b/it0-web-admin/package-lock.json @@ -33,6 +33,7 @@ "react-dom": "^18.3.0", "react-hook-form": "^7.51.0", "react-i18next": "^16.5.4", + "react-qr-code": "^2.0.18", "react-redux": "^9.1.0", "recharts": "^2.12.0", "sonner": "^1.4.0", @@ -7475,6 +7476,12 @@ "node": ">=6" } }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7573,6 +7580,19 @@ "dev": true, "license": "MIT" }, + "node_modules/react-qr-code": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz", + "integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", diff --git a/it0-web-admin/package.json b/it0-web-admin/package.json index 982a208..d9746e8 100644 --- a/it0-web-admin/package.json +++ b/it0-web-admin/package.json @@ -36,6 +36,7 @@ "react-dom": "^18.3.0", "react-hook-form": "^7.51.0", "react-i18next": "^16.5.4", + "react-qr-code": "^2.0.18", "react-redux": "^9.1.0", "recharts": "^2.12.0", "sonner": "^1.4.0", diff --git a/it0-web-admin/src/app/(admin)/my-org/page.tsx b/it0-web-admin/src/app/(admin)/my-org/page.tsx index dce5878..d667c37 100644 --- a/it0-web-admin/src/app/(admin)/my-org/page.tsx +++ b/it0-web-admin/src/app/(admin)/my-org/page.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import QRCode from 'react-qr-code'; import { apiClient } from '@/infrastructure/api/api-client'; interface OrgMember { @@ -45,6 +46,7 @@ export default function MyOrgPage() { const [inviteLink, setInviteLink] = useState(null); const [linkCopied, setLinkCopied] = useState(false); const [inviteError, setInviteError] = useState(null); + const [androidDownloadUrl, setAndroidDownloadUrl] = useState(null); const { data: org, isLoading } = useQuery({ queryKey: ['my-org'], @@ -56,6 +58,13 @@ export default function MyOrgPage() { queryFn: () => apiClient('/api/v1/auth/my-org/invites'), }); + useEffect(() => { + fetch('/api/app/version/check?platform=android¤t_version_code=0') + .then((r) => r.json()) + .then((d) => { if (d?.downloadUrl) setAndroidDownloadUrl(d.downloadUrl); }) + .catch(() => {}); + }, []); + const inviteMutation = useMutation({ mutationFn: (body: { email: string; role: string }) => apiClient('/api/v1/auth/my-org/invite', { method: 'POST', body }), @@ -99,6 +108,57 @@ export default function MyOrgPage() {

+ {/* QR Code row */} +
+ {/* App download QR */} +
+

App 下载二维码

+ {androidDownloadUrl ? ( + <> +
+ +
+

扫码下载 Android App

+ + {androidDownloadUrl} + + + ) : ( +
+ 暂无 App 版本 +
+ )} +
+ + {/* Invite link QR */} +
+

邀请链接二维码

+ {inviteLink ? ( + <> +
+ +
+

扫码加入 {org?.name}

+ + + ) : ( +
+ 发送邀请后此处显示二维码 +
+ )} +
+
+ {/* Invite form */}

邀请成员

diff --git a/packages/services/auth-service/package.json b/packages/services/auth-service/package.json index f753d3e..00f5366 100644 --- a/packages/services/auth-service/package.json +++ b/packages/services/auth-service/package.json @@ -10,28 +10,30 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { - "@nestjs/common": "^10.3.0", - "@nestjs/core": "^10.3.0", - "@nestjs/config": "^3.2.0", - "@nestjs/jwt": "^10.2.0", - "@nestjs/passport": "^10.0.0", - "@nestjs/typeorm": "^10.0.0", - "@nestjs/microservices": "^10.3.0", - "@nestjs/platform-express": "^10.3.0", - "passport": "^0.7.0", - "passport-jwt": "^4.0.1", - "passport-local": "^1.0.0", - "bcryptjs": "^2.4.3", - "typeorm": "^0.3.20", - "pg": "^8.11.0", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.0", - "@it0/common": "workspace:*", - "@it0/database": "workspace:*", "@alicloud/dysmsapi20170525": "^4.3.1", "@alicloud/openapi-client": "^0.4.15", "@alicloud/tea-util": "^1.4.11", - "ioredis": "^5.3.0" + "@it0/common": "workspace:*", + "@it0/database": "workspace:*", + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.3.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/microservices": "^10.3.0", + "@nestjs/passport": "^10.0.0", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/typeorm": "^10.0.0", + "@types/nodemailer": "^6.4.22", + "bcryptjs": "^2.4.3", + "ioredis": "^5.3.0", + "nodemailer": "^6.10.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", + "pg": "^8.11.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0", + "typeorm": "^0.3.20" }, "devDependencies": { "@nestjs/cli": "^10.3.0", diff --git a/packages/services/auth-service/src/application/services/auth.service.ts b/packages/services/auth-service/src/application/services/auth.service.ts index e695d63..00670cb 100644 --- a/packages/services/auth-service/src/application/services/auth.service.ts +++ b/packages/services/auth-service/src/application/services/auth.service.ts @@ -17,6 +17,7 @@ import * as crypto from 'crypto'; import { TenantProvisioningService } from '@it0/database'; import { UserRepository } from '../../infrastructure/repositories/user.repository'; import { ApiKeyRepository } from '../../infrastructure/repositories/api-key.repository'; +import { EmailService } from '../../infrastructure/email/email.service'; import { User } from '../../domain/entities/user.entity'; import { ApiKey } from '../../domain/entities/api-key.entity'; import { Tenant } from '../../domain/entities/tenant.entity'; @@ -40,6 +41,7 @@ export class AuthService { private readonly inviteRepository: Repository, private readonly tenantProvisioningService: TenantProvisioningService, private readonly dataSource: DataSource, + private readonly emailService: EmailService, ) { this.refreshSecret = this.configService.get('JWT_REFRESH_SECRET') || @@ -435,7 +437,17 @@ export class AuthService { invitedBy, }); - return this.inviteRepository.save(invite); + const saved = await this.inviteRepository.save(invite); + + // 发送邀请通知(fire-and-forget,不阻塞响应) + this.emailService.sendInviteEmail({ + to: email, + tenantName: tenant.name, + role: saved.role, + token: saved.token, + }).catch((err) => this.logger.error(`邀请邮件发送失败: ${err.message}`)); + + return saved; } async listInvites(tenantId: string): Promise { diff --git a/packages/services/auth-service/src/auth.module.ts b/packages/services/auth-service/src/auth.module.ts index 7da080e..a239af5 100644 --- a/packages/services/auth-service/src/auth.module.ts +++ b/packages/services/auth-service/src/auth.module.ts @@ -18,6 +18,7 @@ import { AuthService } from './application/services/auth.service'; import { UserRepository } from './infrastructure/repositories/user.repository'; import { ApiKeyRepository } from './infrastructure/repositories/api-key.repository'; import { SmsService } from './infrastructure/sms/sms.service'; +import { EmailService } from './infrastructure/email/email.service'; import { RedisProvider } from './infrastructure/redis/redis.provider'; import { User } from './domain/entities/user.entity'; import { Role } from './domain/entities/role.entity'; @@ -49,6 +50,7 @@ import { TenantTagAssignment } from './domain/entities/tenant-tag-assignment.ent ApiKeyRepository, TenantProvisioningService, SmsService, + EmailService, RedisProvider, ], exports: [AuthService], diff --git a/packages/services/auth-service/src/infrastructure/email/email.service.ts b/packages/services/auth-service/src/infrastructure/email/email.service.ts new file mode 100644 index 0000000..250e24a --- /dev/null +++ b/packages/services/auth-service/src/infrastructure/email/email.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; +import type { Transporter } from 'nodemailer'; + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + private transporter: Transporter | null = null; + private readonly enabled: boolean; + private readonly from: string; + private readonly appBaseUrl: string; + + constructor(private readonly configService: ConfigService) { + this.enabled = this.configService.get('EMAIL_ENABLED') === 'true'; + this.from = this.configService.get('EMAIL_FROM', 'IT0 '); + this.appBaseUrl = this.configService.get('APP_BASE_URL', 'https://it0.szaiai.com'); + + if (this.enabled) { + this.transporter = nodemailer.createTransport({ + host: this.configService.get('EMAIL_HOST'), + port: Number(this.configService.get('EMAIL_PORT', '465')), + secure: this.configService.get('EMAIL_SECURE', 'true') === 'true', + auth: { + user: this.configService.get('EMAIL_USER'), + pass: this.configService.get('EMAIL_PASS'), + }, + }); + this.logger.log('邮件服务已启用'); + } else { + this.logger.warn('邮件服务未配置(EMAIL_ENABLED != true),将使用日志模式'); + } + } + + async sendInviteEmail(params: { + to: string; + tenantName: string; + role: string; + token: string; + inviterName?: string; + }): Promise { + const { to, tenantName, role, token, inviterName } = params; + const inviteUrl = `${this.appBaseUrl}/invite/${token}`; + const roleLabel = this.roleLabel(role); + + const html = ` + + + + + + +
+ + + +
+ IT0 +

AI 智能体运维平台

+
+

您收到一份团队邀请

+

+ ${inviterName ? `${inviterName} 邀请您` : '您被邀请'}加入 ${tenantName},担任 ${roleLabel} 角色。 +

+ +

+ 或复制以下链接到浏览器打开:
+ ${inviteUrl} +

+
+

邀请链接有效期 7 天。如非本人操作请忽略此邮件。

+
+
+ +`; + + const text = `您被邀请加入 ${tenantName}(角色:${roleLabel})\n\n接受邀请:${inviteUrl}\n\n邀请链接有效期 7 天。`; + + if (!this.enabled || !this.transporter) { + this.logger.log(`[Email 模拟] 邀请邮件 → ${to} | 链接: ${inviteUrl}`); + return; + } + + try { + await this.transporter.sendMail({ + from: this.from, + to, + subject: `您已被邀请加入 ${tenantName} — IT0`, + html, + text, + }); + this.logger.log(`[Email] 邀请邮件已发送 → ${to}`); + } catch (error: any) { + this.logger.error(`[Email] 发送失败 → ${to}: ${error.message}`); + // 不抛出,不因邮件失败影响邀请创建 + } + } + + private roleLabel(role: string): string { + const labels: Record = { + admin: '管理员', + operator: '操作员', + viewer: '只读成员', + }; + return labels[role] || role; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 368f761..29f362b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,12 +238,18 @@ importers: '@nestjs/typeorm': specifier: ^10.0.0 version: 10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(ioredis@5.9.2)(pg@8.18.0)) + '@types/nodemailer': + specifier: ^6.4.22 + version: 6.4.22 bcryptjs: specifier: ^2.4.3 version: 2.4.3 ioredis: specifier: ^5.3.0 version: 5.9.2 + nodemailer: + specifier: ^6.10.1 + version: 6.10.1 passport: specifier: ^0.7.0 version: 0.7.0