Compare commits
7 Commits
e211bb2130
...
feat/add-w
| Author | SHA1 | Date | |
|---|---|---|---|
| f2161c68da | |||
|
|
3246479643 | ||
| 5d7bf74101 | |||
| 197cd7ca29 | |||
| c09dbfbd3d | |||
| 84838928e3 | |||
| 296f6e09f8 |
@@ -1,6 +1,25 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: gala-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-gala_root_pass}
|
||||||
|
- MYSQL_DATABASE=${MYSQL_DATABASE:-gala_db}
|
||||||
|
- MYSQL_USER=${MYSQL_USER:-gala_user}
|
||||||
|
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-gala_pass}
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- gala-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: gala-redis
|
container_name: gala-redis
|
||||||
@@ -22,8 +41,15 @@ services:
|
|||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
- REDIS_PORT=6379
|
- REDIS_PORT=6379
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-*}
|
- CORS_ORIGINS=${CORS_ORIGINS:-*}
|
||||||
|
- DATABASE_URL=mysql://${MYSQL_USER:-gala_user}:${MYSQL_PASSWORD:-gala_pass}@mysql:3306/${MYSQL_DATABASE:-gala_db}
|
||||||
|
- WECHAT_APP_ID=${WECHAT_APP_ID}
|
||||||
|
- WECHAT_APP_SECRET=${WECHAT_APP_SECRET}
|
||||||
|
- WECHAT_REDIRECT_URI=${WECHAT_REDIRECT_URI}
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- gala-network
|
- gala-network
|
||||||
|
|
||||||
@@ -49,6 +75,7 @@ services:
|
|||||||
- gala-network
|
- gala-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
mysql_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
|
|||||||
@@ -1,295 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useVotingStore, TICKET_INFO } from '../stores/voting';
|
|
||||||
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
|
|
||||||
|
|
||||||
const votingStore = useVotingStore();
|
|
||||||
|
|
||||||
const stamps = computed(() => {
|
|
||||||
return TICKET_TYPES.map((type) => ({
|
|
||||||
type,
|
|
||||||
...TICKET_INFO[type],
|
|
||||||
isUsed: votingStore.tickets[type] !== null,
|
|
||||||
isSelected: votingStore.selectedStamp === type,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleStampClick(type: TicketType) {
|
|
||||||
if (votingStore.tickets[type] !== null) return; // Already used
|
|
||||||
|
|
||||||
if (votingStore.selectedStamp === type) {
|
|
||||||
votingStore.deselectStamp();
|
|
||||||
} else {
|
|
||||||
votingStore.selectStamp(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="stamp-dock">
|
|
||||||
<!-- Wooden Tray -->
|
|
||||||
<div class="wooden-tray">
|
|
||||||
<!-- Tray Edge (Top) -->
|
|
||||||
<div class="tray-edge tray-edge-top"></div>
|
|
||||||
|
|
||||||
<!-- Stamp Handles -->
|
|
||||||
<div class="stamp-tray">
|
|
||||||
<div
|
|
||||||
v-for="stamp in stamps"
|
|
||||||
:key="stamp.type"
|
|
||||||
class="stamp-handle"
|
|
||||||
:class="{
|
|
||||||
'is-used': stamp.isUsed,
|
|
||||||
'is-selected': stamp.isSelected,
|
|
||||||
}"
|
|
||||||
@click="handleStampClick(stamp.type)"
|
|
||||||
>
|
|
||||||
<!-- Handle Body (Wood) -->
|
|
||||||
<div class="handle-body">
|
|
||||||
<div class="handle-grip"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rubber Base (Shows ink design) -->
|
|
||||||
<div class="rubber-base">
|
|
||||||
<span class="rubber-icon">{{ stamp.icon }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Label -->
|
|
||||||
<span class="stamp-label">{{ stamp.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tray Edge (Bottom) -->
|
|
||||||
<div class="tray-edge tray-edge-bottom"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hint Text -->
|
|
||||||
<div v-if="votingStore.isStampSelected" class="dock-hint">
|
|
||||||
<span class="hint-icon">👆</span>
|
|
||||||
<span class="hint-text">点击节目卡片盖章</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
@use 'sass:color';
|
|
||||||
@use '../assets/styles/variables.scss' as *;
|
|
||||||
|
|
||||||
// Wood colors
|
|
||||||
$wood-light: #d4a574;
|
|
||||||
$wood-medium: #b8864e;
|
|
||||||
$wood-dark: #8b5a2b;
|
|
||||||
$wood-grain: #a0693a;
|
|
||||||
|
|
||||||
.stamp-dock {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: $z-index-fixed;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wooden-tray {
|
|
||||||
background: linear-gradient(180deg, $wood-light 0%, $wood-medium 50%, $wood-dark 100%);
|
|
||||||
border-top: 3px solid $wood-dark;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
// Wood grain texture
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
transparent 2px,
|
|
||||||
rgba($wood-grain, 0.1) 2px,
|
|
||||||
rgba($wood-grain, 0.1) 4px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tray-edge {
|
|
||||||
height: 8px;
|
|
||||||
background: linear-gradient(90deg, $wood-dark, $wood-medium, $wood-dark);
|
|
||||||
|
|
||||||
&.tray-edge-top {
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.tray-edge-bottom {
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stamp-tray {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
padding: $spacing-sm $spacing-xs;
|
|
||||||
gap: 4px;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stamp-handle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.15s ease-out;
|
|
||||||
|
|
||||||
&:active:not(.is-used) {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-used {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
|
|
||||||
.handle-body {
|
|
||||||
filter: grayscale(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rubber-base {
|
|
||||||
filter: grayscale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-selected {
|
|
||||||
transform: translateY(-10px);
|
|
||||||
|
|
||||||
.handle-body {
|
|
||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rubber-base {
|
|
||||||
box-shadow: 0 4px 12px rgba($color-primary, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-body {
|
|
||||||
width: 40px;
|
|
||||||
height: 28px;
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
#654321 0%,
|
|
||||||
#8b4513 30%,
|
|
||||||
#a0522d 50%,
|
|
||||||
#8b4513 70%,
|
|
||||||
#654321 100%
|
|
||||||
);
|
|
||||||
border-radius: 6px 6px 2px 2px;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
transition: box-shadow 0.15s, transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-grip {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 24px;
|
|
||||||
height: 4px;
|
|
||||||
background: linear-gradient(90deg, #5a3a1a, #7a5a3a, #5a3a1a);
|
|
||||||
border-radius: 2px;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 20px;
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(90deg, #5a3a1a, #7a5a3a, #5a3a1a);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rubber-base {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
background: $color-primary;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
|
||||||
transition: box-shadow 0.15s;
|
|
||||||
|
|
||||||
// Rubber texture
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: radial-gradient(
|
|
||||||
circle at 30% 30%,
|
|
||||||
rgba(255, 255, 255, 0.1) 0%,
|
|
||||||
transparent 50%
|
|
||||||
);
|
|
||||||
border-radius: 4px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rubber-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
filter: brightness(0) invert(1); // White icon (reverse of stamp)
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stamp-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #f5f0e6;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 48px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dock-hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: $spacing-xs;
|
|
||||||
padding: $spacing-xs;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
animation: hint-pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
animation: bounce 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-text {
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
color: $color-gold;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hint-pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.7; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounce {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(-4px); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -150,7 +150,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
if (data.userTickets) {
|
if (data.userTickets) {
|
||||||
import('./voting').then(({ useVotingStore }) => {
|
import('./voting').then(({ useVotingStore }) => {
|
||||||
const votingStore = useVotingStore();
|
const votingStore = useVotingStore();
|
||||||
votingStore.syncFromServer(data.userTickets);
|
votingStore.syncFromServer(data.userTickets as Record<string, string | null>);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface AwardConfig {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
order: number;
|
order: number;
|
||||||
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 节目接口
|
// 节目接口
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ onMounted(async () => {
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1 class="page-title">节目投票</h1>
|
<h1 class="page-title">节目投票</h1>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
已使用 {{ votingStore.usedTickets.length }}/7 枚印章
|
已使用 {{ votingStore.usedTicketCount }}/{{ votingStore.totalTicketCount }} 枚印章
|
||||||
</p>
|
</p>
|
||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
import { useDisplayStore } from '../stores/display';
|
||||||
|
import type { WechatLoginSuccessPayload } from '@gala/shared/types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
mobileUrl: string;
|
mobileUrl: string;
|
||||||
@@ -8,14 +10,59 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
|
loginSuccess: [payload: WechatLoginSuccessPayload];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const displayStore = useDisplayStore();
|
||||||
|
|
||||||
const qrCodeDataUrl = ref<string>('');
|
const qrCodeDataUrl = ref<string>('');
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const wechatAuthUrl = ref<string | null>(null);
|
||||||
|
const loginState = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Use WeChat auth URL if available, otherwise fallback to mobile URL
|
||||||
|
const qrUrl = computed(() => wechatAuthUrl.value || props.mobileUrl);
|
||||||
|
|
||||||
|
// Fetch WeChat login URL from server
|
||||||
|
async function fetchWechatLoginUrl() {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = displayStore.getSocket();
|
||||||
|
const socketId = socket?.id;
|
||||||
|
|
||||||
|
if (!socketId) {
|
||||||
|
console.warn('[EntryQRCode] Socket not connected, using fallback URL');
|
||||||
|
await generateQRCode(props.mobileUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || '';
|
||||||
|
const response = await fetch(`${apiUrl}/api/wechat/login?pcSocketId=${socketId}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
wechatAuthUrl.value = result.data.authUrl;
|
||||||
|
loginState.value = result.data.state;
|
||||||
|
await generateQRCode(result.data.authUrl);
|
||||||
|
console.log('[EntryQRCode] WeChat auth URL obtained');
|
||||||
|
} else {
|
||||||
|
console.warn('[EntryQRCode] WeChat not configured, using fallback URL');
|
||||||
|
await generateQRCode(props.mobileUrl);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EntryQRCode] Failed to fetch WeChat login URL:', err);
|
||||||
|
// Fallback to mobile URL
|
||||||
|
await generateQRCode(props.mobileUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate QR code
|
// Generate QR code
|
||||||
async function generateQRCode() {
|
async function generateQRCode(url: string) {
|
||||||
try {
|
try {
|
||||||
qrCodeDataUrl.value = await QRCode.toDataURL(props.mobileUrl, {
|
qrCodeDataUrl.value = await QRCode.toDataURL(url, {
|
||||||
width: 400,
|
width: 400,
|
||||||
margin: 2,
|
margin: 2,
|
||||||
color: {
|
color: {
|
||||||
@@ -23,11 +70,21 @@ async function generateQRCode() {
|
|||||||
light: '#ffffff',
|
light: '#ffffff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
isLoading.value = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to generate QR code:', err);
|
console.error('Failed to generate QR code:', err);
|
||||||
|
error.value = '生成二维码失败';
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle WeChat login success event
|
||||||
|
function handleWechatLoginSuccess(payload: WechatLoginSuccessPayload) {
|
||||||
|
console.log('[EntryQRCode] WeChat login success:', payload);
|
||||||
|
emit('loginSuccess', payload);
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
// Handle ESC key to close
|
// Handle ESC key to close
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -35,12 +92,30 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup WebSocket listener for login success
|
||||||
|
function setupSocketListener() {
|
||||||
|
const socket = displayStore.getSocket();
|
||||||
|
if (socket) {
|
||||||
|
socket.on('wechat:login_success' as any, handleWechatLoginSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup WebSocket listener
|
||||||
|
function cleanupSocketListener() {
|
||||||
|
const socket = displayStore.getSocket();
|
||||||
|
if (socket) {
|
||||||
|
socket.off('wechat:login_success' as any, handleWechatLoginSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
generateQRCode();
|
fetchWechatLoginUrl();
|
||||||
|
setupSocketListener();
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
cleanupSocketListener();
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -59,12 +134,18 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- QR Code -->
|
<!-- QR Code -->
|
||||||
<div class="qrcode-container">
|
<div class="qrcode-container">
|
||||||
<img v-if="qrCodeDataUrl" :src="qrCodeDataUrl" alt="入场二维码" class="qrcode-image" />
|
<div v-if="isLoading" class="qrcode-loading">生成中...</div>
|
||||||
<div v-else class="qrcode-loading">生成中...</div>
|
<div v-else-if="error" class="qrcode-error">{{ error }}</div>
|
||||||
|
<img v-else :src="qrCodeDataUrl" alt="入场二维码" class="qrcode-image" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL hint -->
|
<!-- WeChat logo hint -->
|
||||||
<p class="url-hint">{{ mobileUrl }}</p>
|
<div v-if="wechatAuthUrl" class="wechat-hint">
|
||||||
|
<svg class="wechat-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.406-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
|
||||||
|
</svg>
|
||||||
|
<span>微信扫码登录</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Instructions -->
|
<!-- Instructions -->
|
||||||
<div class="instructions">
|
<div class="instructions">
|
||||||
@@ -74,11 +155,11 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<span class="step-num">2</span>
|
<span class="step-num">2</span>
|
||||||
<span class="step-text">填写姓名和部门</span>
|
<span class="step-text">{{ wechatAuthUrl ? '授权登录' : '填写姓名和部门' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<span class="step-num">3</span>
|
<span class="step-num">3</span>
|
||||||
<span class="step-text">点击进入年会</span>
|
<span class="step-text">{{ wechatAuthUrl ? '自动进入年会' : '点击进入年会' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,7 +244,8 @@ onUnmounted(() => {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qrcode-loading {
|
.qrcode-loading,
|
||||||
|
.qrcode-error {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -173,11 +255,23 @@ onUnmounted(() => {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-hint {
|
.qrcode-error {
|
||||||
font-size: 14px;
|
color: #ff4d4f;
|
||||||
color: $color-text-muted;
|
}
|
||||||
margin-bottom: 32px;
|
|
||||||
font-family: monospace;
|
.wechat-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #07c160;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instructions {
|
.instructions {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ RUN pnpm build
|
|||||||
WORKDIR /app/packages/server
|
WORKDIR /app/packages/server
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Generate Prisma Client in builder stage
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
@@ -39,12 +42,19 @@ COPY --from=builder /app/pnpm-lock.yaml ./
|
|||||||
COPY --from=builder /app/packages/server/package.json ./packages/server/
|
COPY --from=builder /app/packages/server/package.json ./packages/server/
|
||||||
COPY --from=builder /app/packages/shared ./packages/shared
|
COPY --from=builder /app/packages/shared ./packages/shared
|
||||||
|
|
||||||
|
# Copy Prisma schema
|
||||||
|
COPY --from=builder /app/packages/server/prisma ./packages/server/prisma
|
||||||
|
|
||||||
# Install production dependencies only
|
# Install production dependencies only
|
||||||
RUN pnpm install --prod --frozen-lockfile
|
RUN pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy generated Prisma Client from builder stage (pnpm workspace location)
|
||||||
|
COPY --from=builder /app/node_modules/.pnpm ./node_modules/.pnpm
|
||||||
|
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/packages/server/dist ./packages/server/dist
|
COPY --from=builder /app/packages/server/dist ./packages/server/dist
|
||||||
COPY --from=builder /app/packages/server/src/lua ./packages/server/lua
|
COPY --from=builder /app/packages/server/src/lua ./packages/server/lua
|
||||||
|
COPY --from=builder /app/packages/server/config ./packages/config
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -2,59 +2,59 @@
|
|||||||
"programs": [
|
"programs": [
|
||||||
{
|
{
|
||||||
"id": "p1",
|
"id": "p1",
|
||||||
"name": "龙腾四海",
|
"name": "青苹果乐园",
|
||||||
"teamName": "市场部",
|
"teamName": "",
|
||||||
"performer": "张三、李四",
|
"performer": "",
|
||||||
"order": 1,
|
"order": 1,
|
||||||
"remark": "大型民族舞表演,融合了古典与现代元素,展现龙的精神。"
|
"remark": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "p2",
|
"id": "p2",
|
||||||
"name": "金马奔腾",
|
"name": "五百年桑田沧海",
|
||||||
"teamName": "技术部",
|
"teamName": "",
|
||||||
"performer": "王五、赵六",
|
"performer": "",
|
||||||
"order": 2,
|
"order": 2,
|
||||||
"remark": "动感的现代舞,充满力量与节奏感。"
|
"remark": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "p3",
|
"id": "p3",
|
||||||
"name": "春风得意",
|
"name": "我的中国心",
|
||||||
"teamName": "人力资源部",
|
"teamName": "",
|
||||||
"performer": "刘七、陈八",
|
"performer": "",
|
||||||
"order": 3,
|
"order": 3,
|
||||||
"remark": "温馨的情景剧,讲述了职场中的温暖瞬间。"
|
"remark": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "p4",
|
"id": "p4",
|
||||||
"name": "鸿运当头",
|
"name": "萍聚",
|
||||||
"teamName": "财务部",
|
"teamName": "",
|
||||||
"performer": "周九、吴十",
|
"performer": "",
|
||||||
"order": 4,
|
"order": 4,
|
||||||
"remark": "精彩的杂技表演,寓意新年鸿运连连。"
|
"remark": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "p5",
|
"id": "p5",
|
||||||
"name": "马到成功",
|
"name": "追光而行,共赴新程",
|
||||||
"teamName": "运营部",
|
"teamName": "",
|
||||||
"performer": "郑十一、冯十二",
|
"performer": "",
|
||||||
"order": 5,
|
"order": 5,
|
||||||
"remark": "热血沸腾的多人合唱,充满了前进的动力。"
|
"remark": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "p6",
|
"id": "p6",
|
||||||
"name": "一马当先",
|
"name": "粉红色的回忆",
|
||||||
"teamName": "产品部",
|
"teamName": "",
|
||||||
"performer": "孙十三、杨十四",
|
"performer": "",
|
||||||
"order": 6,
|
"order": 6,
|
||||||
"remark": "极具创意的光影秀,探索未来科技的可能。"
|
"remark": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "p7",
|
"id": "p7",
|
||||||
"name": "万马奔腾",
|
"name": "敬业狂想曲",
|
||||||
"teamName": "设计部",
|
"teamName": "",
|
||||||
"performer": "何十五、林十六",
|
"performer": "",
|
||||||
"order": 7,
|
"order": 7,
|
||||||
"remark": "大合唱,展现团队的凝聚力和向心力。"
|
"remark": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"awards": [
|
"awards": [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { requestLogger } from './middleware/requestLogger';
|
|||||||
import voteRoutes from './routes/vote.routes';
|
import voteRoutes from './routes/vote.routes';
|
||||||
import adminRoutes from './routes/admin.routes';
|
import adminRoutes from './routes/admin.routes';
|
||||||
import scanRoutes from './routes/scan.routes';
|
import scanRoutes from './routes/scan.routes';
|
||||||
|
import wechatRoutes from './routes/wechat.routes';
|
||||||
|
|
||||||
export const app: Application = express();
|
export const app: Application = express();
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ app.get('/health', (_req, res) => {
|
|||||||
app.use('/api/vote', voteRoutes);
|
app.use('/api/vote', voteRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/scan', scanRoutes);
|
app.use('/api/scan', scanRoutes);
|
||||||
|
app.use('/api/wechat', wechatRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ export const config = {
|
|||||||
batchSize: 100,
|
batchSize: 100,
|
||||||
intervalMs: 1000,
|
intervalMs: 1000,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// WeChat Open Platform
|
||||||
|
wechat: {
|
||||||
|
appId: process.env.WECHAT_APP_ID || '',
|
||||||
|
appSecret: process.env.WECHAT_APP_SECRET || '',
|
||||||
|
redirectUri: process.env.WECHAT_REDIRECT_URI || '',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Config = typeof config;
|
export type Config = typeof config;
|
||||||
|
|||||||
145
packages/server/src/routes/wechat.routes.ts
Normal file
145
packages/server/src/routes/wechat.routes.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { wechatService } from '../services/wechat.service';
|
||||||
|
import { getIO } from '../socket';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wechat/login
|
||||||
|
* Generate WeChat login QR code URL
|
||||||
|
*/
|
||||||
|
router.get('/login', async (req: Request, res: Response) => {
|
||||||
|
const { pcSocketId } = req.query;
|
||||||
|
|
||||||
|
if (!pcSocketId || typeof pcSocketId !== 'string') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'pcSocketId is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wechatService.createLoginState(pcSocketId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wechat/callback
|
||||||
|
* WeChat OAuth2 callback handler
|
||||||
|
*/
|
||||||
|
router.get('/callback', async (req: Request, res: Response) => {
|
||||||
|
const { code, state } = req.query;
|
||||||
|
|
||||||
|
if (!code || !state || typeof code !== 'string' || typeof state !== 'string') {
|
||||||
|
logger.warn({ code, state }, 'Invalid WeChat callback parameters');
|
||||||
|
return res.status(400).send(renderCallbackPage(false, 'Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wechatService.handleCallback(code, state);
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
logger.error({ error: result.error }, 'WeChat callback failed');
|
||||||
|
return res.status(400).send(renderCallbackPage(false, result.error || 'Login failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { openid, pcSocketId, sessionToken, userId, userInfo } = result.data;
|
||||||
|
|
||||||
|
// Notify PC client via WebSocket
|
||||||
|
try {
|
||||||
|
const io = getIO();
|
||||||
|
io.to(pcSocketId).emit('wechat:login_success' as any, {
|
||||||
|
openid,
|
||||||
|
sessionToken,
|
||||||
|
userId,
|
||||||
|
userName: userInfo?.nickname || `微信用户_${openid.slice(-6)}`,
|
||||||
|
userInfo,
|
||||||
|
});
|
||||||
|
logger.info({ pcSocketId, userId }, 'WeChat login success notification sent');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, pcSocketId }, 'Failed to notify PC client');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success page
|
||||||
|
res.send(renderCallbackPage(true, 'Login successful'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wechat/status
|
||||||
|
* Check WeChat configuration status
|
||||||
|
*/
|
||||||
|
router.get('/status', (_req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
configured: wechatService.isConfigured(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render callback result page
|
||||||
|
*/
|
||||||
|
function renderCallbackPage(success: boolean, message: string): string {
|
||||||
|
const color = success ? '#52c41a' : '#ff4d4f';
|
||||||
|
const icon = success ? '✓' : '✗';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>微信登录${success ? '成功' : '失败'}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${color};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">${icon}</div>
|
||||||
|
<div class="message">${message}</div>
|
||||||
|
<div class="hint">${success ? '请返回大屏查看' : '请重新扫码'}</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
${success ? 'setTimeout(() => window.close(), 3000);' : ''}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
255
packages/server/src/services/wechat.service.ts
Normal file
255
packages/server/src/services/wechat.service.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { redis } from '../config/redis';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import type {
|
||||||
|
WechatLoginState,
|
||||||
|
WechatAccessTokenResponse,
|
||||||
|
WechatUserInfo,
|
||||||
|
} from '@gala/shared/types';
|
||||||
|
|
||||||
|
const WECHAT_STATE_PREFIX = 'wechat:state:';
|
||||||
|
const SESSION_TOKEN_PREFIX = 'session:';
|
||||||
|
const STATE_TTL = 5 * 60; // 5 minutes
|
||||||
|
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
|
||||||
|
|
||||||
|
class WechatService {
|
||||||
|
private readonly appId: string;
|
||||||
|
private readonly appSecret: string;
|
||||||
|
private readonly redirectUri: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.appId = config.wechat.appId;
|
||||||
|
this.appSecret = config.wechat.appSecret;
|
||||||
|
this.redirectUri = config.wechat.redirectUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if WeChat config is valid
|
||||||
|
*/
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return !!(this.appId && this.appSecret && this.redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate WeChat OAuth2 authorization URL
|
||||||
|
*/
|
||||||
|
generateAuthUrl(state: string): string {
|
||||||
|
const baseUrl = 'https://open.weixin.qq.com/connect/qrconnect';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
appid: this.appId,
|
||||||
|
redirect_uri: this.redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'snsapi_login',
|
||||||
|
state: state,
|
||||||
|
});
|
||||||
|
return `${baseUrl}?${params.toString()}#wechat_redirect`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create login state and store in Redis
|
||||||
|
*/
|
||||||
|
async createLoginState(pcSocketId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { authUrl: string; state: string; expiresAt: number };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return { success: false, error: 'WeChat not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = uuidv4();
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = now + STATE_TTL * 1000;
|
||||||
|
|
||||||
|
const stateData: WechatLoginState = {
|
||||||
|
state,
|
||||||
|
pcSocketId,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
await redis.setex(
|
||||||
|
`${WECHAT_STATE_PREFIX}${state}`,
|
||||||
|
STATE_TTL,
|
||||||
|
JSON.stringify(stateData)
|
||||||
|
);
|
||||||
|
|
||||||
|
const authUrl = this.generateAuthUrl(state);
|
||||||
|
|
||||||
|
logger.info({ state, pcSocketId }, 'WeChat login state created');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { authUrl, state, expiresAt },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to create WeChat login state');
|
||||||
|
return { success: false, error: 'Failed to create login state' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange code for access_token and openid
|
||||||
|
*/
|
||||||
|
async getAccessToken(code: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { openid: string; accessToken: string };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const url = 'https://api.weixin.qq.com/sns/oauth2/access_token';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
appid: this.appId,
|
||||||
|
secret: this.appSecret,
|
||||||
|
code: code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${url}?${params.toString()}`);
|
||||||
|
const data: WechatAccessTokenResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.errcode) {
|
||||||
|
logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat access_token error');
|
||||||
|
return { success: false, error: data.errmsg || 'Failed to get access token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.openid || !data.access_token) {
|
||||||
|
return { success: false, error: 'Invalid response from WeChat' };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ openid: data.openid }, 'WeChat access_token obtained');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { openid: data.openid, accessToken: data.access_token },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to get WeChat access token');
|
||||||
|
return { success: false, error: 'Failed to get access token' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user info from WeChat (optional, requires user consent)
|
||||||
|
*/
|
||||||
|
async getUserInfo(accessToken: string, openid: string): Promise<WechatUserInfo | null> {
|
||||||
|
try {
|
||||||
|
const url = 'https://api.weixin.qq.com/sns/userinfo';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
access_token: accessToken,
|
||||||
|
openid: openid,
|
||||||
|
lang: 'zh_CN',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${url}?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.errcode) {
|
||||||
|
logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat userinfo error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as WechatUserInfo;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to get WeChat user info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle WeChat callback - validate state and exchange code
|
||||||
|
*/
|
||||||
|
async handleCallback(code: string, state: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
openid: string;
|
||||||
|
pcSocketId: string;
|
||||||
|
sessionToken: string;
|
||||||
|
userId: string;
|
||||||
|
userInfo?: WechatUserInfo;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// Validate state
|
||||||
|
const stateKey = `${WECHAT_STATE_PREFIX}${state}`;
|
||||||
|
const stateDataStr = await redis.get(stateKey);
|
||||||
|
|
||||||
|
if (!stateDataStr) {
|
||||||
|
return { success: false, error: 'Invalid or expired state' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateData: WechatLoginState = JSON.parse(stateDataStr);
|
||||||
|
|
||||||
|
if (Date.now() > stateData.expiresAt) {
|
||||||
|
await redis.del(stateKey);
|
||||||
|
return { success: false, error: 'State expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for access_token
|
||||||
|
const tokenResult = await this.getAccessToken(code);
|
||||||
|
if (!tokenResult.success || !tokenResult.data) {
|
||||||
|
return { success: false, error: tokenResult.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { openid, accessToken } = tokenResult.data;
|
||||||
|
|
||||||
|
// Optionally get user info
|
||||||
|
const userInfo = await this.getUserInfo(accessToken, openid);
|
||||||
|
|
||||||
|
// Generate user ID and session token
|
||||||
|
const userId = `wx_${openid.slice(0, 16)}`;
|
||||||
|
const userName = userInfo?.nickname || `微信用户_${openid.slice(-6)}`;
|
||||||
|
|
||||||
|
const sessionToken = jwt.sign(
|
||||||
|
{ userId, userName, openid },
|
||||||
|
config.jwtSecret || 'gala-secret-key',
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store session
|
||||||
|
await redis.setex(
|
||||||
|
`${SESSION_TOKEN_PREFIX}${sessionToken}`,
|
||||||
|
SESSION_TOKEN_TTL,
|
||||||
|
JSON.stringify({ userId, userName, openid })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete used state
|
||||||
|
await redis.del(stateKey);
|
||||||
|
|
||||||
|
logger.info({ openid, userId, pcSocketId: stateData.pcSocketId }, 'WeChat login successful');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
openid,
|
||||||
|
pcSocketId: stateData.pcSocketId,
|
||||||
|
sessionToken,
|
||||||
|
userId,
|
||||||
|
userInfo: userInfo || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, state }, 'Failed to handle WeChat callback');
|
||||||
|
return { success: false, error: 'Failed to process login' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get state data (for debugging/status check)
|
||||||
|
*/
|
||||||
|
async getStateData(state: string): Promise<WechatLoginState | null> {
|
||||||
|
try {
|
||||||
|
const data = await redis.get(`${WECHAT_STATE_PREFIX}${state}`);
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, state }, 'Failed to get state data');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wechatService = new WechatService();
|
||||||
@@ -4,3 +4,4 @@ export * from './vote.types';
|
|||||||
export * from './draw.types';
|
export * from './draw.types';
|
||||||
export * from './admin.types';
|
export * from './admin.types';
|
||||||
export * from './scan-login.types';
|
export * from './scan-login.types';
|
||||||
|
export * from './wechat.types';
|
||||||
|
|||||||
54
packages/shared/src/types/wechat.types.ts
Normal file
54
packages/shared/src/types/wechat.types.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// WeChat Open Platform login types
|
||||||
|
|
||||||
|
export interface WechatLoginState {
|
||||||
|
state: string;
|
||||||
|
pcSocketId: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatLoginResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
authUrl: string;
|
||||||
|
state: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatCallbackResult {
|
||||||
|
success: boolean;
|
||||||
|
openid?: string;
|
||||||
|
pcSocketId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatAccessTokenResponse {
|
||||||
|
access_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
openid?: string;
|
||||||
|
scope?: string;
|
||||||
|
errcode?: number;
|
||||||
|
errmsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatUserInfo {
|
||||||
|
openid: string;
|
||||||
|
nickname?: string;
|
||||||
|
sex?: number;
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
headimgurl?: string;
|
||||||
|
privilege?: string[];
|
||||||
|
unionid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatLoginSuccessPayload {
|
||||||
|
openid: string;
|
||||||
|
sessionToken: string;
|
||||||
|
userId: string;
|
||||||
|
userInfo?: WechatUserInfo;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user