Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.git
.github
._*
.claude
.turbo
node_modules
Expand Down
81 changes: 81 additions & 0 deletions .github/workflows/electron-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Electron Release

on:
push:
tags:
- 'v*.*.*'

permissions:
contents: write

concurrency:
group: electron-release-${{ github.ref }}
cancel-in-progress: false

jobs:
macos:
runs-on: macos-15

steps:
- name: 检出代码
uses: actions/checkout@v4

- name: 校验版本
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
VERSION="$(node -p "require('./apps/election/package.json').version")"
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$GITHUB_REF_NAME" == "v$VERSION" ]]
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
echo "$GITHUB_REF_NAME 已存在"
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"

- name: 安装 pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.4

- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: 安装依赖
run: pnpm install --frozen-lockfile

- name: 测试与检查桌面端
run: |
pnpm --filter @lecpunch/election test
pnpm --filter @lecpunch/election typecheck

- name: 构建 macOS 安装包
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: pnpm --filter @lecpunch/election package:mac

- name: 校验产物并生成校验和
run: |
set -euo pipefail
test "$(find apps/election/release -maxdepth 1 -name '*.dmg' | wc -l | tr -d ' ')" = 2
file apps/election/release/mac-arm64/LecPunch\ Election.app/Contents/MacOS/LecPunch\ Election | grep -q arm64
file apps/election/release/mac/LecPunch\ Election.app/Contents/MacOS/LecPunch\ Election | grep -q x86_64
shasum -a 256 apps/election/release/*.dmg apps/election/release/*.zip > apps/election/release/SHA256SUMS.txt

- name: 创建待审核 GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" \
apps/election/release/*.dmg \
apps/election/release/*.zip \
apps/election/release/SHA256SUMS.txt \
--target "$GITHUB_SHA" \
--title "LecPunch Election $VERSION" \
--generate-notes \
--draft
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ tools/

# Misc
.DS_Store
._*
31 changes: 21 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ LecPunch 是一个团队打卡系统 monorepo。当前仓库已经收口到 V1.1

当前已纳入范围的能力:

- 用户名 / 密码登录,以及可开关的开放注册
- Web 与 Election 桌面端统一使用 LEC SSO(OIDC Authorization Code + PKCE)
- 以后端为真值的上卡 / 下卡业务规则
- 当前打卡状态、我的记录、成员记录、个人周历史
- 团队本周统计与同团队访问控制
- 个人资料编辑与密码修改
- LecPunch 展示资料编辑;密码和身份资料由 LEC SSO 统一管理
- 后台成员管理
- 后台网络策略管理
- 后台团队记录 CSV 导出
Expand Down Expand Up @@ -72,9 +72,12 @@ Copy-Item apps/api/.env.example apps/api/.env
关键变量说明:

- `MONGODB_URI`:MongoDB 连接串
- `AUTH_SECRET`:JWT 密钥,至少 16 位
- `DEFAULT_TEAM_NAME`:注册和 seed 流程使用的默认团队名
- `ALLOW_OPEN_REGISTRATION`:是否允许 `/auth/register`
- `LOGTO_ENDPOINT`:LEC SSO 地址;生产环境必须使用 HTTPS
- `LOGTO_API_RESOURCE`:LecPunch API audience,默认 `https://api.lecpunch.lec.org`
- `LOGTO_ORGANIZATION_ID`:获准访问的 Logto Organization ID,默认 `lec-studio`
- `AUTH_SECRET`:成员分享链接的 HMAC 密钥,至少 16 位(不用于登录 JWT)
- `LECPUNCH_TEAM_ID`:生产环境建议固定为现有 Mongo 团队 ID
- `DEFAULT_TEAM_NAME`:未配置团队 ID 时使用的默认团队名
- `ALLOW_ANY_NETWORK`:设为 `false` 后才真正启用打卡网络白名单
- `ALLOWED_PUBLIC_IPS`:逗号分隔的精确客户端 IP 白名单
- `ALLOWED_CIDRS`:逗号分隔的 CIDR 白名单
Expand All @@ -84,7 +87,7 @@ Copy-Item apps/api/.env.example apps/api/.env
本地演示建议保持:

```env
ALLOW_OPEN_REGISTRATION=true
LOGTO_ENDPOINT=http://localhost:3001
ALLOW_ANY_NETWORK=true
```

Expand All @@ -98,17 +101,25 @@ ALLOW_ANY_NETWORK=true
Copy-Item apps/web/.env.example apps/web/.env
```

本地开发建议把 `VITE_API_BASE_URL` 留空,让 Vite 通过 `/api` 代理到 `http://localhost:4000`:
本地开发让 Vite 通过 `/api` 代理到 API,并配置 LecPunch Web SPA:

```env
VITE_API_BASE_URL=
VITE_LOGTO_ENDPOINT=http://localhost:3001
VITE_LOGTO_APP_ID=lecpunch-web
VITE_LOGTO_RESOURCE=https://api.lecpunch.lec.org
VITE_LOGTO_ORGANIZATION_ID=lec-studio
```

只有在前端需要直连已部署 API 时,才填写完整地址。
只有在前端需要直连已部署 API 时,才填写完整地址。生产环境的 API 与 SSO 均必须使用 HTTPS。

### Election 桌面端

复制 `apps/election/.env.example` 后配置 `LECPUNCH_API_BASE_URL` 与 `LECPUNCH_LOGTO_*`。桌面端通过系统浏览器完成 PKCE 登录,Refresh Token 仅由 Electron 主进程使用 `safeStorage` 加密保存,Access Token 只保留在内存并通过受限 IPC 获取。

## 种子数据

API 工作区已经提供可重复执行的演示数据脚本:
API 工作区保留可重复执行的业务演示数据脚本;这些旧本地密码账号不能用于 SSO 登录,仅用于非认证数据准备:

```powershell
pnpm --filter @lecpunch/api seed
Expand All @@ -119,7 +130,7 @@ pnpm --filter @lecpunch/api seed
- `demo-admin` / `123456`
- `demo-member` / `123456`

可用 `demo-admin` 验证后台入口:
使用在 LEC SSO 中拥有 `studio:admin:access` 的 Captain 账号验证后台入口:

- `/admin/members`
- `/admin/network-policy`
Expand Down
13 changes: 10 additions & 3 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
NODE_ENV=development
PORT=4000
MONGODB_URI=mongodb://localhost:27017/lecpunch
# Minimum 16 characters. Replace in shared or production environments.
# LEC SSO issuer and organization-scoped API resource.
LOGTO_ENDPOINT=http://localhost:3001
LOGTO_API_RESOURCE=https://api.lecpunch.lec.org
LOGTO_ORGANIZATION_ID=lec-studio
# 用于成员链接 HMAC,不用于登录 JWT。生产环境必须替换。
AUTH_SECRET=change-me-please-1234
# 本地会议令牌使用独立密钥;部署时替换为队长机器共享的随机密钥。
MEET_JWT_APP_ID=lecpunch-lan-meet
MEET_JWT_SECRET=local-meeting-secret-change-me-please
# 生产环境建议固定到现有 Mongo team _id,避免身份迁移时创建第二个团队。
LECPUNCH_TEAM_ID=
DEFAULT_TEAM_NAME=FocusTeam
# Keep true only for local development or demo environments.
ALLOW_OPEN_REGISTRATION=true
# Enables balanced accounting (server-credited slices + pause/resume semantics).
ATTENDANCE_BALANCED_ACCOUNTING_ENABLED=true
# When false, configure at least one allowlist below.
Expand Down
1 change: 1 addition & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ ENV npm_config_registry=$NPM_REGISTRY
ENV NPM_CONFIG_USERCONFIG=/app/.npmrc
ENV npm_config_userconfig=/app/.npmrc
ENV XDG_CONFIG_HOME=/tmp/xdg
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1

WORKDIR /app

Expand Down
9 changes: 3 additions & 6 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,19 @@
"@nestjs/common": "^11.1.17",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.17",
"@nestjs/jwt": "^11.0.2",
"@nestjs/jwt": "^12.0.3",
"@nestjs/mongoose": "^11.0.4",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.17",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"ip-cidr": "^4.0.2",
"ipaddr.js": "^2.3.0",
"joi": "^18.1.2",
"jose": "^6.2.12",
"luxon": "^3.7.2",
"mongoose": "^9.3.3",
"multer": "^2.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1"
"multer": "^2.0.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.17",
Expand All @@ -42,7 +40,6 @@
"@types/luxon": "^3.7.1",
"@types/multer": "^2.0.0",
"@types/node": "^25.5.0",
"@types/passport-jwt": "^4.0.1",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsx": "^4.20.5",
Expand Down
2 changes: 0 additions & 2 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { GitHubSourcesModule } from './modules/github-sources/github-sources.mod
import { WeeklyReportsModule } from './modules/weekly-reports/weekly-reports.module';
import { PointsModule } from './modules/points/points.module';
import { MeetModule } from './modules/meet/meet.module';
import { ShopModule } from './modules/shop/shop.module';

@Module({
imports: [
Expand All @@ -40,7 +39,6 @@ import { ShopModule } from './modules/shop/shop.module';
GitHubSourcesModule,
WeeklyReportsModule,
PointsModule,
ShopModule,
MeetModule,
AttendanceModule,
RecordsModule,
Expand Down
5 changes: 0 additions & 5 deletions apps/api/src/common/constants/shop.constants.ts

This file was deleted.

25 changes: 22 additions & 3 deletions apps/api/src/common/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import { ERROR_CODES } from '@lecpunch/shared';
import { LogtoAuthService } from '../../modules/auth/logto-auth.service';
import type { AuthUser } from '../../modules/auth/types/auth-user.type';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
export class JwtAuthGuard implements CanActivate {
constructor(private readonly auth: LogtoAuthService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const authorization = request.headers.authorization;
const match = /^Bearer\s+(.+)$/i.exec(authorization ?? '');
if (!match?.[1]) {
throw new UnauthorizedException({
code: ERROR_CODES.AUTH_UNAUTHORIZED,
message: '缺少登录凭证'
});
}
(request as Request & { user: AuthUser }).user = await this.auth.authenticate(match[1]);
return true;
}
}
27 changes: 27 additions & 0 deletions apps/api/src/config/env.validation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,41 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { validationSchema } from './env.validation';

const baseEnv = {
MONGODB_URI: 'mongodb://localhost:27017/lecpunch',
LOGTO_ENDPOINT: 'http://localhost:3001',
LOGTO_API_RESOURCE: 'https://api.lecpunch.lec.org',
LOGTO_ORGANIZATION_ID: 'lec-studio',
AUTH_SECRET: 'a'.repeat(64),
MEET_JWT_APP_ID: 'lecpunch-lan-meet',
MEET_JWT_SECRET: 'f'.repeat(64)
};

describe('validationSchema', () => {
it.each(['.env.example', '../../deploy/api.env.example'])(
'accepts the documented API configuration from %s',
(file) => {
const entries = readFileSync(resolve(process.cwd(), file), 'utf8')
.split(/\r?\n/)
.filter((line) => line.trim() && !line.trim().startsWith('#'))
.map((line) => {
const separator = line.indexOf('=');
return [line.slice(0, separator), line.slice(separator + 1)];
});

const { error } = validationSchema.validate(Object.fromEntries(entries));
expect(error).toBeUndefined();
}
);

it('accepts an empty optional team ID and still rejects malformed IDs', () => {
expect(validationSchema.validate({ ...baseEnv, LECPUNCH_TEAM_ID: '' }).error).toBeUndefined();
expect(validationSchema.validate({ ...baseEnv, LECPUNCH_TEAM_ID: 'not-a-team-id' }).error)
.toBeDefined();
});

it('rejects invalid exact IP entries', () => {
const { error } = validationSchema.validate({
...baseEnv,
Expand Down
11 changes: 7 additions & 4 deletions apps/api/src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@ export const validationSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().default(4000),
MONGODB_URI: Joi.string().uri().required(),
LOGTO_ENDPOINT: Joi.string().uri({ scheme: ['http', 'https'] }).required(),
LOGTO_API_RESOURCE: Joi.string().uri({ scheme: ['http', 'https'] }).required(),
LOGTO_ORGANIZATION_ID: Joi.string().default('lec-studio'),
AUTH_SECRET: Joi.string().min(16).required(),
MEET_JWT_APP_ID: Joi.string().pattern(/^[A-Za-z0-9][A-Za-z0-9_-]{2,63}$/).required(),
MEET_JWT_SECRET: Joi.string().min(32).required(),
LECPUNCH_TEAM_ID: Joi.string().pattern(/^[a-f\d]{24}$/i).allow('').optional(),
DEFAULT_TEAM_NAME: Joi.string().default('FocusTeam'),
ALLOW_OPEN_REGISTRATION: Joi.boolean().truthy('true').falsy('false').default(true),
ATTENDANCE_BALANCED_ACCOUNTING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
Expand All @@ -69,15 +73,14 @@ export const validationSchema = Joi.object({
if (!value.ALLOW_ANY_NETWORK && !hasAllowedIps && !hasAllowedCidrs) {
return helpers.error('network.allowlistRequired');
}

if (value.MEET_JWT_SECRET === value.AUTH_SECRET) {
return helpers.error('meet.secretMustDiffer');
if (value.AUTH_SECRET === value.MEET_JWT_SECRET) {
return helpers.error('secret.reused');
}

return value;
})
.messages({
'network.allowlistRequired':
'ALLOWED_PUBLIC_IPS or ALLOWED_CIDRS must be configured when ALLOW_ANY_NETWORK=false',
'meet.secretMustDiffer': 'MEET_JWT_SECRET must differ from AUTH_SECRET'
'secret.reused': 'MEET_JWT_SECRET must differ from AUTH_SECRET'
});
3 changes: 3 additions & 0 deletions apps/api/src/modules/attendance/attendance.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,9 @@ export class AttendanceService {
return {
memberKey: this.usersService.getMemberKey(session.userId),
displayName: user?.displayName ?? '未知成员',
username: user?.username,
realName: user?.realName,
avatarUrl: user?.avatarUrl,
enrollYear: user?.enrollYear ?? 0,
avatarColor: user?.avatarColor,
avatarEmoji: user?.avatarEmoji,
Expand Down
Loading
Loading