commit 5a156b2d87bcc8f03dc1110ebab746281443cc7a Author: Codex Bot Date: Mon Sep 21 03:38:09 2026 +0800 feat: build CoffeeChat mini program and backend diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cf86a73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitea +node_modules +.env +server/uploads +project.private.config.json +cloudfunctions +docs +miniprogram + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec3afc0 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +NODE_ENV=production +PORT=3000 +DATABASE_URL=postgres://coffeechat:change-me@db:5432/coffeechat +JWT_SECRET=replace-with-at-least-32-random-characters +WECHAT_APP_ID=wx386640f47786131f +WECHAT_APP_SECRET=replace-with-wechat-app-secret +ADMIN_TOKEN=replace-with-a-long-random-admin-token +PUBLIC_BASE_URL=https://coffeechat.example.com + diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..11f96e0 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI +on: + push: + pull_request: +jobs: + test: + runs-on: minecraft-and-affine-server + steps: + - uses: actions/checkout@v4 + - name: Check and test in Node 22 + run: docker run --rm -v "$PWD:/app" -w /app node:22-alpine sh -c "npm ci && npm run check && npm test" diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..673f4af --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,19 @@ +name: Deploy production +on: + workflow_dispatch: +jobs: + deploy: + runs-on: minecraft-and-affine-server + concurrency: coffeechat-production + steps: + - uses: actions/checkout@v4 + - name: Test + run: docker run --rm -v "$PWD:/app" -w /app node:22-alpine sh -c "npm ci && npm run check && npm test" + - name: Load production environment + run: test -f /opt/coffeechat/.env && cp /opt/coffeechat/.env .env + - name: Build and migrate + run: docker compose --env-file .env -f deploy/docker-compose.yml build --pull + - name: Start production + run: docker compose --env-file .env -f deploy/docker-compose.yml up -d --remove-orphans + - name: Verify health + run: curl --fail --retry 12 --retry-delay 5 http://127.0.0.1:${COFFEECHAT_PORT:-3100}/health diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0658f77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.env +.DS_Store +coverage/ +server/uploads/ +project.private.config.json +npm-debug.log* + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0e54dc --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# CoffeeChat + +面向会展现场社交的微信小程序。用户通过微信登录后,可浏览和筛选活动、查看会展信息、加入活动、填写参与目的,并在管理员审核后查看活动微信群。 + +## 目录 + +- `miniprogram/`:原生微信小程序 +- `server/`:Node.js API、管理后台和数据库迁移 +- `deploy/`:Docker Compose 生产部署 +- `.gitea/workflows/`:Gitea Actions 测试及部署 +- `docs/`:接口、配置和上线说明 + +## 本地启动 + +1. 复制 `.env.example` 为 `.env`,填写微信 AppSecret 和安全密钥。 +2. 执行 `docker compose -f deploy/docker-compose.yml up --build`。 +3. 将 `miniprogram/config.js` 中的 `apiBaseUrl` 改为已加入微信后台 request 合法域名的 HTTPS 地址。 +4. 使用微信开发者工具打开仓库根目录。 + +管理后台位于 `https://你的域名/admin/`,登录凭据为环境变量 `ADMIN_TOKEN`。 + +完整部署及微信平台配置见 [docs/deployment.md](docs/deployment.md)。 + diff --git a/cloudfunctions/quickstartFunctions/config.json b/cloudfunctions/quickstartFunctions/config.json new file mode 100644 index 0000000..41a485c --- /dev/null +++ b/cloudfunctions/quickstartFunctions/config.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "openapi": [ + "wxacode.get" + ] + } +} \ No newline at end of file diff --git a/cloudfunctions/quickstartFunctions/index.js b/cloudfunctions/quickstartFunctions/index.js new file mode 100644 index 0000000..61efb8f --- /dev/null +++ b/cloudfunctions/quickstartFunctions/index.js @@ -0,0 +1,185 @@ +const cloud = require("wx-server-sdk"); +cloud.init({ + env: cloud.DYNAMIC_CURRENT_ENV, +}); + +const db = cloud.database(); +// 获取openid +const getOpenId = async () => { + // 获取基础信息 + const wxContext = cloud.getWXContext(); + return { + openid: wxContext.OPENID, + appid: wxContext.APPID, + unionid: wxContext.UNIONID, + }; +}; + +// 获取小程序二维码 +const getMiniProgramCode = async () => { + // 获取小程序二维码的buffer + const resp = await cloud.openapi.wxacode.get({ + path: "pages/index/index", + }); + const { buffer } = resp; + // 将图片上传云存储空间 + const upload = await cloud.uploadFile({ + cloudPath: "code.png", + fileContent: buffer, + }); + return upload.fileID; +}; + +// 创建集合 +const createCollection = async () => { + try { + // 创建集合 + await db.createCollection("sales"); + await db.collection("sales").add({ + // data 字段表示需新增的 JSON 数据 + data: { + region: "华东", + city: "上海", + sales: 11, + }, + }); + await db.collection("sales").add({ + // data 字段表示需新增的 JSON 数据 + data: { + region: "华东", + city: "南京", + sales: 11, + }, + }); + await db.collection("sales").add({ + // data 字段表示需新增的 JSON 数据 + data: { + region: "华南", + city: "广州", + sales: 22, + }, + }); + await db.collection("sales").add({ + // data 字段表示需新增的 JSON 数据 + data: { + region: "华南", + city: "深圳", + sales: 22, + }, + }); + return { + success: true, + }; + } catch (e) { + // 这里catch到的是该collection已经存在,从业务逻辑上来说是运行成功的,所以catch返回success给前端,避免工具在前端抛出异常 + return { + success: true, + data: "create collection success", + }; + } +}; + +// 查询数据 +const selectRecord = async () => { + // 返回数据库查询结果 + return await db.collection("sales").get(); +}; + +// 更新数据 +const updateRecord = async (event) => { + try { + // 遍历修改数据库信息 + for (let i = 0; i < event.data.length; i++) { + await db + .collection("sales") + .where({ + _id: event.data[i]._id, + }) + .update({ + data: { + sales: event.data[i].sales, + }, + }); + } + return { + success: true, + data: event.data, + }; + } catch (e) { + return { + success: false, + errMsg: e, + }; + } +}; + +// 新增数据 +const insertRecord = async (event) => { + try { + const insertRecord = event.data; + // 插入数据 + await db.collection("sales").add({ + data: { + region: insertRecord.region, + city: insertRecord.city, + sales: Number(insertRecord.sales), + }, + }); + return { + success: true, + data: event.data, + }; + } catch (e) { + return { + success: false, + errMsg: e, + }; + } +}; + +// 删除数据 +const deleteRecord = async (event) => { + try { + await db + .collection("sales") + .where({ + _id: event.data._id, + }) + .remove(); + return { + success: true, + }; + } catch (e) { + return { + success: false, + errMsg: e, + }; + } +}; + +// const getOpenId = require('./getOpenId/index'); +// const getMiniProgramCode = require('./getMiniProgramCode/index'); +// const createCollection = require('./createCollection/index'); +// const selectRecord = require('./selectRecord/index'); +// const updateRecord = require('./updateRecord/index'); +// const fetchGoodsList = require('./fetchGoodsList/index'); +// const genMpQrcode = require('./genMpQrcode/index'); +// 云函数入口函数 +exports.main = async (event, context) => { + switch (event.type) { + case "getOpenId": + return await getOpenId(); + case "getMiniProgramCode": + return await getMiniProgramCode(); + case "createCollection": + return await createCollection(); + case "selectRecord": + return await selectRecord(); + case "updateRecord": + return await updateRecord(event); + case "insertRecord": + return await insertRecord(event); + case "deleteRecord": + return await deleteRecord(event); + } +}; diff --git a/cloudfunctions/quickstartFunctions/package.json b/cloudfunctions/quickstartFunctions/package.json new file mode 100644 index 0000000..4350dbb --- /dev/null +++ b/cloudfunctions/quickstartFunctions/package.json @@ -0,0 +1,14 @@ +{ + "name": "quickstartFunctions", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "wx-server-sdk": "~2.4.0" + } +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..8574f66 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,52 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: coffeechat + POSTGRES_USER: coffeechat + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + volumes: + - coffeechat-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U coffeechat"] + interval: 5s + timeout: 3s + retries: 20 + migrate: + image: postgres:17-alpine + depends_on: + db: { condition: service_healthy } + environment: + PGPASSWORD: ${POSTGRES_PASSWORD} + volumes: + - ../server/migrations:/migrations:ro + entrypoint: ["sh", "-c", "for f in /migrations/*.sql; do psql -h db -U coffeechat -d coffeechat -v ON_ERROR_STOP=1 -f \"$$f\"; done"] + restart: "no" + api: + build: + context: .. + dockerfile: server/Dockerfile + restart: unless-stopped + depends_on: + db: { condition: service_healthy } + migrate: { condition: service_completed_successfully } + environment: + NODE_ENV: production + PORT: 3000 + DATABASE_URL: postgres://coffeechat:${POSTGRES_PASSWORD}@db:5432/coffeechat + JWT_SECRET: ${JWT_SECRET} + WECHAT_APP_ID: ${WECHAT_APP_ID} + WECHAT_APP_SECRET: ${WECHAT_APP_SECRET} + ADMIN_TOKEN: ${ADMIN_TOKEN} + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL} + UPLOAD_DIR: /app/uploads + volumes: + - coffeechat-uploads:/app/uploads + ports: + - "127.0.0.1:${COFFEECHAT_PORT:-3100}:3000" +volumes: + coffeechat-db: + name: coffeechat-db + coffeechat-uploads: + name: coffeechat-uploads diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..abe3fca --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,34 @@ +# 生产部署 + +## 1. 主机准备 + +`minecraft-and-affine-server` Gitea Runner 需要可执行 Docker Compose,并且 Runner 服务账号必须能读取 `/opt/coffeechat/.env`。建议将该文件权限限制为该账号可读: + +```dotenv +POSTGRES_PASSWORD=随机数据库密码 +JWT_SECRET=至少32位随机字符串 +WECHAT_APP_ID=小程序AppID +WECHAT_APP_SECRET=小程序AppSecret +ADMIN_TOKEN=随机管理令牌 +PUBLIC_BASE_URL=https://你的正式域名 +COFFEECHAT_PORT=3100 +``` + +推送 `main` 后,Runner 会构建容器、执行幂等迁移并启动服务。PostgreSQL 和上传图片分别保存在 `coffeechat-db`、`coffeechat-uploads` Docker 卷中,重新发布不会丢失。 + +## 2. HTTPS 反向代理 + +将正式域名反向代理至 `127.0.0.1:3100`,必须启用有效 HTTPS。健康检查路径为 `/health`,管理后台为 `/admin/`。 + +## 3. 微信公众平台 + +1. 在“开发管理 → 开发设置”获取 AppSecret,填入服务器环境文件,不得提交仓库。 +2. 将正式 HTTPS 域名加入 `request` 和 `uploadFile` 合法域名。 +3. 修改 `miniprogram/config.js` 的 `apiBaseUrl`。 +4. 使用微信开发者工具完成真机测试、代码上传和体验版审核。 + +微信群通过二维码加入。二维码由用户提交,管理员在 Web 后台审核;审核通过前不会对其他参与者展示。微信群二维码可能过期,管理员应在审核时验证有效性。 + +## 4. 数据备份 + +建议每天备份 `coffeechat-db` 卷中的 PostgreSQL,并同步备份 `coffeechat-uploads` 卷。恢复时必须同时恢复数据库与上传目录,避免图片记录与文件不一致。 diff --git a/miniprogram/app.js b/miniprogram/app.js new file mode 100644 index 0000000..caf0639 --- /dev/null +++ b/miniprogram/app.js @@ -0,0 +1,7 @@ +const api = require('./utils/api') +App({ + globalData: { user: wx.getStorageSync('user') || null }, + onLaunch() { this.loginPromise = this.login() }, + login() { return new Promise((resolve, reject) => wx.login({ success: ({ code }) => api.request('/api/auth/wechat', { method: 'POST', data: { code } }).then(({ token, user }) => { wx.setStorageSync('token', token); wx.setStorageSync('user', user); this.globalData.user = user; resolve(user) }).catch(reject), fail: reject })) }, + ensureLogin() { return this.loginPromise || this.login() } +}) diff --git a/miniprogram/app.json b/miniprogram/app.json new file mode 100644 index 0000000..5e779e5 --- /dev/null +++ b/miniprogram/app.json @@ -0,0 +1,14 @@ +{ + "pages": ["pages/index/index", "pages/activity/index", "pages/profile/index", "pages/login/index"], + "window": { + "backgroundColor": "#F5F1EA", + "backgroundTextStyle": "light", + "navigationBarBackgroundColor": "#F5F1EA", + "navigationBarTitleText": "CoffeeChat", + "navigationBarTextStyle": "black" + }, + "tabBar": {"color":"#8A7D71","selectedColor":"#2E5B47","backgroundColor":"#FFFDF9","list":[{"pagePath":"pages/index/index","text":"活动","iconPath":"images/icons/home.png","selectedIconPath":"images/icons/home-active.png"},{"pagePath":"pages/profile/index","text":"我的","iconPath":"images/icons/usercenter.png","selectedIconPath":"images/icons/usercenter-active.png"}]}, + "sitemapLocation": "sitemap.json", + "style": "v2", + "lazyCodeLoading": "requiredComponents" +} diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss new file mode 100644 index 0000000..6ae3b16 --- /dev/null +++ b/miniprogram/app.wxss @@ -0,0 +1 @@ +.page{min-height:100vh;background:#f5f1ea;color:#241f1b;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}.safe{padding:28rpx 30rpx 60rpx}.card{background:#fffdf9;border:1rpx solid #e5dacd;border-radius:28rpx;box-shadow:0 16rpx 42rpx rgba(74,54,38,.08)}.primary{background:#2e5b47!important;color:#fff!important;border-radius:999rpx;font-weight:600}.secondary{background:#eadfd2!important;color:#5d4738!important;border-radius:999rpx}.muted{color:#8a7d71}.tag{display:inline-block;padding:8rpx 16rpx;border-radius:999rpx;background:#ebe4ff;color:#6347c5;font-size:22rpx;margin-right:10rpx}.empty{text-align:center;color:#8a7d71;padding:100rpx 0}button::after{border:none}page{background:#f5f1ea} diff --git a/miniprogram/components/cloudTipModal/index.js b/miniprogram/components/cloudTipModal/index.js new file mode 100644 index 0000000..8e75cc0 --- /dev/null +++ b/miniprogram/components/cloudTipModal/index.js @@ -0,0 +1,28 @@ +Component({ + + /** + * 页面的初始数据 + */ + data: { + showTip: false, + }, + properties: { + showTipProps: Boolean, + title:String, + content:String + }, + observers: { + showTipProps: function(showTipProps) { + this.setData({ + showTip: showTipProps + }); + } + }, + methods: { + onClose(){ + this.setData({ + showTip: !this.data.showTip + }); + }, + } +}); diff --git a/miniprogram/components/cloudTipModal/index.json b/miniprogram/components/cloudTipModal/index.json new file mode 100644 index 0000000..ee2988f --- /dev/null +++ b/miniprogram/components/cloudTipModal/index.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "component": true +} diff --git a/miniprogram/components/cloudTipModal/index.wxml b/miniprogram/components/cloudTipModal/index.wxml new file mode 100644 index 0000000..b5b6700 --- /dev/null +++ b/miniprogram/components/cloudTipModal/index.wxml @@ -0,0 +1,10 @@ + + + + + + + {{title}} + {{content}} + + diff --git a/miniprogram/components/cloudTipModal/index.wxss b/miniprogram/components/cloudTipModal/index.wxss new file mode 100644 index 0000000..862f70c --- /dev/null +++ b/miniprogram/components/cloudTipModal/index.wxss @@ -0,0 +1,60 @@ +.install_tip_back { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: rgba(0,0,0,0.4); + z-index: 1; +} +.install_tip_close{ + position:absolute; + right: 10rpx; + top: 10rpx; + width: 32px; + height: 32px; + /* background-color: red; */ +} +.install_tip_detail { + position: fixed; + background-color: white; + right: 0; + bottom: 0; + left: 0; + border-radius: 40rpx 40rpx 0 0; + padding: 50rpx 50rpx 100rpx 50rpx; + z-index: 9; +} + +.install_tip_detail_title { + font-weight: 400; + font-size: 40rpx; + text-align: center; +} + +.install_tip_detail_tip { + font-size: 25rpx; + color: rgba(0,0,0,0.4); + margin-top: 20rpx; + text-align: left; +} +.install_tip_detail_buttons { + padding-top: 50rpx; + display: flex; +} +.install_tip_detail_button { + color: #07C160; + font-weight: 500; + background-color: rgba(0,0,0,0.1); + width: 40%; + text-align: center; + /* height: 90rpx; */ + /* line-height: 90rpx; */ + border-radius: 10rpx; + margin: 0 auto; +} + +.install_tip_detail_button_primary { + background-color: #07C160; + color: #fff; +} \ No newline at end of file diff --git a/miniprogram/config.js b/miniprogram/config.js new file mode 100644 index 0000000..91f68a9 --- /dev/null +++ b/miniprogram/config.js @@ -0,0 +1,2 @@ +module.exports = { apiBaseUrl: 'https://coffeechat.example.com' } + diff --git a/miniprogram/envList.js b/miniprogram/envList.js new file mode 100644 index 0000000..e9a169e --- /dev/null +++ b/miniprogram/envList.js @@ -0,0 +1,6 @@ +const envList = []; +const isMac = false; +module.exports = { + envList, + isMac +}; diff --git a/miniprogram/images/ai_example1.png b/miniprogram/images/ai_example1.png new file mode 100644 index 0000000..c1f452d Binary files /dev/null and b/miniprogram/images/ai_example1.png differ diff --git a/miniprogram/images/ai_example2.png b/miniprogram/images/ai_example2.png new file mode 100644 index 0000000..1b6e26c Binary files /dev/null and b/miniprogram/images/ai_example2.png differ diff --git a/miniprogram/images/arrow.svg b/miniprogram/images/arrow.svg new file mode 100644 index 0000000..cd32a7d --- /dev/null +++ b/miniprogram/images/arrow.svg @@ -0,0 +1,11 @@ + + + ☀ iOS/☀ 图标/线型/icons_outlined_arrow@3x + + + + + + + + \ No newline at end of file diff --git a/miniprogram/images/avatar.png b/miniprogram/images/avatar.png new file mode 100644 index 0000000..48d5ee2 Binary files /dev/null and b/miniprogram/images/avatar.png differ diff --git a/miniprogram/images/cloud_dev.png b/miniprogram/images/cloud_dev.png new file mode 100644 index 0000000..9eb538a Binary files /dev/null and b/miniprogram/images/cloud_dev.png differ diff --git a/miniprogram/images/copy.svg b/miniprogram/images/copy.svg new file mode 100644 index 0000000..63759cb --- /dev/null +++ b/miniprogram/images/copy.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/miniprogram/images/create_cbr.png b/miniprogram/images/create_cbr.png new file mode 100644 index 0000000..8eded5c Binary files /dev/null and b/miniprogram/images/create_cbr.png differ diff --git a/miniprogram/images/create_cbrf.png b/miniprogram/images/create_cbrf.png new file mode 100644 index 0000000..d076f11 Binary files /dev/null and b/miniprogram/images/create_cbrf.png differ diff --git a/miniprogram/images/create_env.png b/miniprogram/images/create_env.png new file mode 100644 index 0000000..c319b9d Binary files /dev/null and b/miniprogram/images/create_env.png differ diff --git a/miniprogram/images/database.png b/miniprogram/images/database.png new file mode 100644 index 0000000..6551376 Binary files /dev/null and b/miniprogram/images/database.png differ diff --git a/miniprogram/images/database_add.png b/miniprogram/images/database_add.png new file mode 100644 index 0000000..f4ac9f9 Binary files /dev/null and b/miniprogram/images/database_add.png differ diff --git a/miniprogram/images/default-goods-image.png b/miniprogram/images/default-goods-image.png new file mode 100644 index 0000000..a5015f3 Binary files /dev/null and b/miniprogram/images/default-goods-image.png differ diff --git a/miniprogram/images/env-select.png b/miniprogram/images/env-select.png new file mode 100644 index 0000000..36a7b6a Binary files /dev/null and b/miniprogram/images/env-select.png differ diff --git a/miniprogram/images/function_deploy.png b/miniprogram/images/function_deploy.png new file mode 100644 index 0000000..8d9c7a6 Binary files /dev/null and b/miniprogram/images/function_deploy.png differ diff --git a/miniprogram/images/icons/avatar.png b/miniprogram/images/icons/avatar.png new file mode 100644 index 0000000..45744b3 Binary files /dev/null and b/miniprogram/images/icons/avatar.png differ diff --git a/miniprogram/images/icons/business-active.png b/miniprogram/images/icons/business-active.png new file mode 100644 index 0000000..690c5b3 Binary files /dev/null and b/miniprogram/images/icons/business-active.png differ diff --git a/miniprogram/images/icons/business.png b/miniprogram/images/icons/business.png new file mode 100644 index 0000000..af0fbcf Binary files /dev/null and b/miniprogram/images/icons/business.png differ diff --git a/miniprogram/images/icons/close.png b/miniprogram/images/icons/close.png new file mode 100644 index 0000000..18f01a2 Binary files /dev/null and b/miniprogram/images/icons/close.png differ diff --git a/miniprogram/images/icons/copy.png b/miniprogram/images/icons/copy.png new file mode 100644 index 0000000..d2d8f61 Binary files /dev/null and b/miniprogram/images/icons/copy.png differ diff --git a/miniprogram/images/icons/customer-service.svg b/miniprogram/images/icons/customer-service.svg new file mode 100644 index 0000000..be7bba4 --- /dev/null +++ b/miniprogram/images/icons/customer-service.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniprogram/images/icons/examples-active.png b/miniprogram/images/icons/examples-active.png new file mode 100644 index 0000000..a5715d7 Binary files /dev/null and b/miniprogram/images/icons/examples-active.png differ diff --git a/miniprogram/images/icons/examples.png b/miniprogram/images/icons/examples.png new file mode 100644 index 0000000..7ce740b Binary files /dev/null and b/miniprogram/images/icons/examples.png differ diff --git a/miniprogram/images/icons/goods-active.png b/miniprogram/images/icons/goods-active.png new file mode 100644 index 0000000..54a4680 Binary files /dev/null and b/miniprogram/images/icons/goods-active.png differ diff --git a/miniprogram/images/icons/goods.png b/miniprogram/images/icons/goods.png new file mode 100644 index 0000000..c1ec7ed Binary files /dev/null and b/miniprogram/images/icons/goods.png differ diff --git a/miniprogram/images/icons/home-active.png b/miniprogram/images/icons/home-active.png new file mode 100644 index 0000000..c1107cb Binary files /dev/null and b/miniprogram/images/icons/home-active.png differ diff --git a/miniprogram/images/icons/home.png b/miniprogram/images/icons/home.png new file mode 100644 index 0000000..e598e18 Binary files /dev/null and b/miniprogram/images/icons/home.png differ diff --git a/miniprogram/images/icons/question.svg b/miniprogram/images/icons/question.svg new file mode 100644 index 0000000..3389b03 --- /dev/null +++ b/miniprogram/images/icons/question.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/miniprogram/images/icons/setting.svg b/miniprogram/images/icons/setting.svg new file mode 100644 index 0000000..1c41dab --- /dev/null +++ b/miniprogram/images/icons/setting.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/images/icons/share.svg b/miniprogram/images/icons/share.svg new file mode 100644 index 0000000..297275e --- /dev/null +++ b/miniprogram/images/icons/share.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/images/icons/usercenter-active.png b/miniprogram/images/icons/usercenter-active.png new file mode 100644 index 0000000..38a93cf Binary files /dev/null and b/miniprogram/images/icons/usercenter-active.png differ diff --git a/miniprogram/images/icons/usercenter.png b/miniprogram/images/icons/usercenter.png new file mode 100644 index 0000000..e0e0131 Binary files /dev/null and b/miniprogram/images/icons/usercenter.png differ diff --git a/miniprogram/images/scf-enter.png b/miniprogram/images/scf-enter.png new file mode 100644 index 0000000..d71fb3a Binary files /dev/null and b/miniprogram/images/scf-enter.png differ diff --git a/miniprogram/pages/activity/index.js b/miniprogram/pages/activity/index.js new file mode 100644 index 0000000..bae22b9 --- /dev/null +++ b/miniprogram/pages/activity/index.js @@ -0,0 +1,2 @@ +const api=require('../../utils/api');const app=getApp(); +Page({data:{id:'',activity:null,user:null,joined:false,purpose:'',showPurpose:false,submitting:false},onLoad({id}){this.setData({id,user:app.globalData.user||wx.getStorageSync('user')});app.ensureLogin().then(user=>{this.setData({user});this.load()}).catch(()=>this.load())},onShow(){if(this.data.activity)this.load()},async load(){try{const activity=await api.request(`/api/activities/${this.data.id}`);const mine=activity.participants.find(p=>this.data.user&&p.id===this.data.user.id);this.setData({activity,joined:!!mine,purpose:mine?mine.purpose:''})}catch(e){wx.showToast({title:e.message,icon:'none'})}},preview(e){const urls=this.data.activity.images.map(x=>x.url);wx.previewImage({current:e.currentTarget.dataset.url,urls})},editPurpose(){this.setData({showPurpose:true})},purposeInput(e){this.setData({purpose:e.detail.value})},closePurpose(){this.setData({showPurpose:false})},async savePurpose(){this.setData({submitting:true});try{await api.request(`/api/activities/${this.data.id}/participations/me`,{method:'PUT',data:{purpose:this.data.purpose}});this.setData({showPurpose:false});await this.load();wx.showToast({title:'已加入'})}catch(e){wx.showToast({title:e.message,icon:'none'})}finally{this.setData({submitting:false})}},leave(){wx.showModal({title:'退出活动?',content:'你的参与目的也会被移除',success:async r=>{if(r.confirm){await api.request(`/api/activities/${this.data.id}/participations/me`,{method:'DELETE'});this.load()}}})},openGroup(e){wx.previewImage({urls:[e.currentTarget.dataset.url]})},submitGroup(){wx.chooseMedia({count:1,mediaType:['image'],success:async r=>{wx.showLoading({title:'上传中'});try{const file=await api.upload(r.tempFiles[0].tempFilePath);await api.request(`/api/activities/${this.data.id}/participations/groups`,{method:'POST',data:{name:`${this.data.activity.title}交流群`,qrImageUrl:file.url,note:'用户提交,待管理员核验'}});wx.showToast({title:'已提交审核'})}catch(e){wx.showToast({title:e.message,icon:'none'})}finally{wx.hideLoading()}}})}}) diff --git a/miniprogram/pages/activity/index.json b/miniprogram/pages/activity/index.json new file mode 100644 index 0000000..bac4079 --- /dev/null +++ b/miniprogram/pages/activity/index.json @@ -0,0 +1 @@ +{"navigationBarTitleText":"活动详情","backgroundColor":"#f5f1ea"} diff --git a/miniprogram/pages/activity/index.wxml b/miniprogram/pages/activity/index.wxml new file mode 100644 index 0000000..b8c4b50 --- /dev/null +++ b/miniprogram/pages/activity/index.wxml @@ -0,0 +1 @@ +CoffeeChat{{activity.status==='active'?'正在进行':'即将开始'}}{{activity.title}}{{item.name}}时间{{activity.startsAt}}地点{{activity.location || activity.exhibition.venue}}活动介绍{{activity.description}}所属会展{{activity.exhibition.name}}{{activity.exhibition.startsAt}} 至 {{activity.exhibition.endsAt}}{{activity.exhibition.venue}} · {{activity.exhibition.address}}{{activity.exhibition.description}}会遇见的人{{activity.participants.length}} 人成为第一个加入的人吧{{item.nickname}}{{item.purpose || '期待现场交流'}}活动微信群提交群二维码暂无已审核的群{{item.name}}点击查看二维码你为什么想参加? diff --git a/miniprogram/pages/activity/index.wxss b/miniprogram/pages/activity/index.wxss new file mode 100644 index 0000000..918c6f3 --- /dev/null +++ b/miniprogram/pages/activity/index.wxss @@ -0,0 +1 @@ +.gallery{height:560rpx;background:#ddd}.gallery image{width:100%;height:100%}.placeholder{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#315c49,#9d7256);color:white;font:italic 56rpx serif}.content{padding:34rpx 30rpx 180rpx}.status{color:#a15f3c;font-size:24rpx}.title{font:700 54rpx/1.2 serif;margin:14rpx 0 20rpx}.facts{padding:12rpx 26rpx;margin:30rpx 0}.facts view{display:flex;gap:24rpx;padding:20rpx 0;border-bottom:1rpx solid #eee5db}.facts view:last-child{border:0}.facts text{color:#8a7d71;width:70rpx}.facts b{font-size:27rpx}.section{margin:46rpx 0}.section-title{font-size:34rpx;font-weight:700;margin-bottom:20rpx}.description{line-height:1.8;color:#5f534a;white-space:pre-wrap}.expo{padding:28rpx;display:flex;flex-direction:column;gap:10rpx}.overline{font-size:20rpx;letter-spacing:3rpx;color:#9b7256}.expo-title{font-size:35rpx;font-weight:700}.section-head{display:flex;justify-content:space-between;align-items:center}.link{color:#2e5b47;font-weight:600}.person{display:flex;gap:18rpx;padding:18rpx 0;border-bottom:1rpx solid #e5dbcf}.person image{width:76rpx;height:76rpx;border-radius:50%}.person view{display:flex;flex-direction:column;gap:6rpx}.person text{color:#76695e;font-size:25rpx}.group{display:flex;align-items:center;padding:18rpx;margin:12rpx 0;gap:18rpx}.group image{width:90rpx;height:90rpx;border-radius:12rpx}.group view{display:flex;flex-direction:column;gap:8rpx}.group text{font-size:23rpx;color:#807267}.footer{position:fixed;left:0;right:0;bottom:0;padding:20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));display:flex;gap:12rpx;background:#fffdf9;border-top:1rpx solid #e2d8cc}.footer button{flex:1}.footer .text-btn{flex:0 0 120rpx;color:#a74b3f}.mask{position:fixed;inset:0;background:#241f1b88;display:flex;align-items:flex-end;z-index:10}.sheet{width:100%;background:#fffdf9;border-radius:32rpx 32rpx 0 0;padding:38rpx 30rpx calc(34rpx + env(safe-area-inset-bottom))}.sheet textarea{width:100%;box-sizing:border-box;background:#f3ede6;border-radius:20rpx;padding:22rpx;margin:10rpx 0 26rpx;height:220rpx} diff --git a/miniprogram/pages/example/index.js b/miniprogram/pages/example/index.js new file mode 100644 index 0000000..400f4e3 --- /dev/null +++ b/miniprogram/pages/example/index.js @@ -0,0 +1,606 @@ +// pages/exampleDetail/index.js +Page({ + data: { + type: "", + envId: "", + showTip: false, + title: "", + content: "", + + haveGetOpenId: false, + openId: "", + + haveGetCodeSrc: false, + codeSrc: "", + + haveGetRecord: false, + record: [], + + haveGetImgSrc: false, + imgSrc: "", + + // ai + modelConfig: { + modelProvider: "deepseek", // 大模型服务厂商 + quickResponseModel: "deepseek-v3", // 快速响应模型 (混元 turbo, gpt4 turbo版,deepseek v3等) + logo: "https://cloudcache.tencent-cloud.com/qcloud/ui/static/static_source_business/2339414f-2c0d-4537-9618-1812bd14f4af.svg", // model 头像 + welcomeMsg: "我是deepseek-v3,很高兴见到你!", // model 欢迎语 + }, + callcbrCode: "", + initEnvCode: "", + callOpenIdCode: "", + callMiniProgramCode: "", + callFunctionCode: "", + callCreateCollectionCode: "", + callUploadFileCode: "", + + showInsertModal: false, + insertRegion: "", + insertCity: "", + insertSales: "", + + haveGetCallContainerRes: false, + callContainerResStr: "", + + ai_page_config: `{ + "usingComponents": { + "agent-ui":"/components/agent-ui/index" + }, +}`, + ai_wxml_config: `<agent-ui agentConfig="{{agentConfig}}" showBotAvatar="{{showBotAvatar}}" chatMode="{{chatMode}}" modelConfig="{{modelConfig}}""></agent-ui>`, + ai_data_config: `data: { + chatMode: "bot", // bot 表示使用agent,model 表示使用大模型 + showBotAvatar: true, // 是否在对话框左侧显示头像 + agentConfig: { + botId: "your agent id", // agent id, + allowWebSearch: true, // 允许客户端选择展示联网搜索按钮 + allowUploadFile: true, // 允许客户端展示上传文件按钮 + allowPullRefresh: true, // 允许客户端展示下拉刷新 + allowUploadImage: true, // 允许客户端展示上传图片按钮 + allowMultiConversation: true, // 允许客户端展示查看会话列表/新建会话按钮 + showToolCallDetail: true, // 是否展示 mcp server toolCall 细节 + allowVoice: true, // 允许客户端展示语音按钮 + showBotName: true, // 允许展示bot名称 + }, + modelConfig: { + modelProvider: "hunyuan-open", // 大模型服务厂商 + quickResponseModel: "hunyuan-lite", // 大模型名称 + logo: "", // model 头像 + welcomeMsg: "欢迎语", // model 欢迎语 + }, +}`, + + // AI 场景示例数据 + aiScenarios: [ + { + title: "💡 智能代码生成与补全", + examples: [ + "帮我创建一个商品列表页面,包含图片、标题、价格和加入购物车按钮", + "帮我完善这个函数,实现商品搜索功能", + ], + }, + { + title: "🔧 代码优化与重构建议", + examples: [ + "优化这段代码的性能,减少不必要的渲染", + "完善云函数调用的错误处理代码", + ], + }, + ], + }, + + onLoad(options) { + if ( + options.type === "cloudbaserunfunction" || + options.type === "cloudbaserun" + ) { + this.getCallcbrCode(); + } + if (options.type === "getOpenId") { + this.getOpenIdCode(); + } + if (options.type === "getMiniProgramCode") { + this.getMiniProgramCode(); + } + + if (options.type === "createCollection") { + this.getCreateCollectionCode(); + } + + if (options.type === "uploadFile") { + this.getUploadFileCode(); + } + this.setData({ type: options?.type, envId: options?.envId }); + }, + + copyUrl() { + wx.setClipboardData({ + data: "https://gitee.com/TencentCloudBase/cloudbase-agent-ui/tree/main/apps/miniprogram-agent-ui/miniprogram/components/agent-ui", + success: function (res) { + wx.showToast({ + title: "复制成功", + icon: "success", + }); + }, + }); + }, + + copyPluginName() { + wx.setClipboardData({ + data: "微信云开发 AI ToolKit", + success: function (res) { + wx.showToast({ + title: "复制成功", + icon: "success", + }); + }, + }); + }, + + copyPrompt(e) { + const prompt = e.currentTarget.dataset.prompt; + wx.setClipboardData({ + data: prompt, + success: function (res) { + wx.showToast({ + title: "复制成功", + icon: "success", + }); + }, + }); + }, + + insertRecord() { + this.setData({ + showInsertModal: true, + insertRegion: "", + insertCity: "", + insertSales: "", + }); + }, + + deleteRecord(e) { + // 调用云函数删除记录 + wx.showLoading({ + title: "删除中...", + }); + wx.cloud + .callFunction({ + name: "quickstartFunctions", + data: { + type: "deleteRecord", + data: { + _id: e.currentTarget.dataset.id, + }, + }, + }) + .then((resp) => { + wx.showToast({ + title: "删除成功", + }); + this.getRecord(); // 刷新列表 + wx.hideLoading(); + }) + .catch((e) => { + wx.showToast({ + title: "删除失败", + icon: "none", + }); + wx.hideLoading(); + }); + }, + + // 输入框事件 + onInsertRegionInput(e) { + this.setData({ insertRegion: e.detail.value }); + }, + onInsertCityInput(e) { + this.setData({ insertCity: e.detail.value }); + }, + onInsertSalesInput(e) { + this.setData({ insertSales: e.detail.value }); + }, + // 取消弹窗 + onInsertCancel() { + this.setData({ showInsertModal: false }); + }, + + // 确认插入 + async onInsertConfirm() { + const { insertRegion, insertCity, insertSales } = this.data; + if (!insertRegion || !insertCity || !insertSales) { + wx.showToast({ title: "请填写完整信息", icon: "none" }); + return; + } + wx.showLoading({ title: "插入中..." }); + try { + await wx.cloud.callFunction({ + name: "quickstartFunctions", + data: { + type: "insertRecord", + data: { + region: insertRegion, + city: insertCity, + sales: Number(insertSales), + }, + }, + }); + wx.showToast({ title: "插入成功" }); + this.setData({ showInsertModal: false }); + this.getRecord(); // 刷新列表 + } catch (e) { + wx.showToast({ title: "插入失败", icon: "none" }); + console.error(e); + } finally { + wx.hideLoading(); + } + }, + + getOpenId() { + wx.showLoading({ + title: "", + }); + wx.cloud + .callFunction({ + name: "quickstartFunctions", + data: { + type: "getOpenId", + }, + }) + .then((resp) => { + this.setData({ + haveGetOpenId: true, + openId: resp.result.openid, + }); + wx.hideLoading(); + }) + .catch((e) => { + wx.hideLoading(); + const { errCode, errMsg } = e; + if (errMsg.includes("Environment not found")) { + this.setData({ + showTip: true, + title: "云开发环境未找到", + content: + "如果已经开通云开发,请检查环境ID与 `miniprogram/app.js` 中的 `env` 参数是否一致。", + }); + return; + } + if (errMsg.includes("FunctionName parameter could not be found")) { + this.setData({ + showTip: true, + title: "请上传云函数", + content: + "在'cloudfunctions/quickstartFunctions'目录右键,选择【上传并部署-云端安装依赖】,等待云函数上传完成后重试。", + }); + return; + } + }); + }, + + clearOpenId() { + this.setData({ + haveGetOpenId: false, + openId: "", + }); + }, + + clearCallContainerRes() { + this.setData({ + haveGetCallContainerRes: false, + callContainerResStr: "", + }); + }, + + getCodeSrc() { + wx.showLoading({ + title: "", + }); + wx.cloud + .callFunction({ + name: "quickstartFunctions", + data: { + type: "getMiniProgramCode", + }, + }) + .then((resp) => { + this.setData({ + haveGetCodeSrc: true, + codeSrc: resp.result, + }); + wx.hideLoading(); + }) + .catch((e) => { + wx.hideLoading(); + console.error(e); + const { errCode, errMsg } = e; + if (errMsg.includes("Environment not found")) { + this.setData({ + showTip: true, + title: "云开发环境未找到", + content: + "如果已经开通云开发,请检查环境ID与 `miniprogram/app.js` 中的 `env` 参数是否一致。", + }); + return; + } + if (errMsg.includes("FunctionName parameter could not be found")) { + this.setData({ + showTip: true, + title: "请上传云函数", + content: + "在'cloudfunctions/quickstartFunctions'目录右键,选择【上传并部署-云端安装依赖】,等待云函数上传完成后重试。", + }); + return; + } + }); + }, + + clearCodeSrc() { + this.setData({ + haveGetCodeSrc: false, + codeSrc: "", + }); + }, + + bindInput(e) { + const index = e.currentTarget.dataset.index; + const record = this.data.record; + record[index].sales = Number(e.detail.value); + this.setData({ + record, + }); + }, + + getRecord() { + wx.showLoading({ + title: "", + }); + wx.cloud + .callFunction({ + name: "quickstartFunctions", + data: { + type: "selectRecord", + }, + }) + .then((resp) => { + this.setData({ + haveGetRecord: true, + record: resp.result.data, + }); + wx.hideLoading(); + }) + .catch((e) => { + this.setData({ + showTip: true, + }); + wx.hideLoading(); + console.error(e); + }); + }, + + clearRecord() { + this.setData({ + haveGetRecord: false, + record: [], + }); + }, + updateRecord() { + wx.showLoading({ + title: "", + }); + wx.cloud + .callFunction({ + name: "quickstartFunctions", + data: { + type: "updateRecord", + data: this.data.record, + }, + }) + .then((resp) => { + wx.showToast({ + title: "更新成功", + }); + wx.hideLoading(); + }) + .catch((e) => { + console.log(e); + this.setData({ + showUploadTip: true, + }); + wx.hideLoading(); + }); + }, + + uploadImg() { + wx.showLoading({ + title: "", + }); + // 让用户选择一张图片 + wx.chooseMedia({ + count: 1, + success: (chooseResult) => { + // 将图片上传至云存储空间 + wx.cloud + .uploadFile({ + // 指定上传到的云路径 + cloudPath: `my-photo-${new Date().getTime()}.png`, + // 指定要上传的文件的小程序临时文件路径 + filePath: chooseResult.tempFiles[0].tempFilePath, + }) + .then((res) => { + this.setData({ + haveGetImgSrc: true, + imgSrc: res.fileID, + }); + }) + .catch((e) => { + console.log("e", e); + }); + }, + complete: () => { + wx.hideLoading(); + }, + }); + }, + + clearImgSrc() { + this.setData({ + haveGetImgSrc: false, + imgSrc: "", + }); + }, + + goOfficialWebsite() { + const url = "https://docs.cloudbase.net/toolbox/quick-start"; + wx.navigateTo({ + url: `../web/index?url=${url}`, + }); + }, + runCallContainer: async function () { + const app = getApp(); + console.log("globalData", app.globalData); + const c1 = new wx.cloud.Cloud({ + resourceEnv: app.globalData.env, + }); + await c1.init(); + const r = await c1.callContainer({ + path: "/api/users", // 填入业务自定义路径 + header: { + "X-WX-SERVICE": "express-test", // 填入服务名称 + }, + // 其余参数同 wx.request + method: "GET", + }); + console.log(r); + this.setData({ + haveGetCallContainerRes: true, + callContainerResStr: `${JSON.stringify(r.data.items, null, 2)}`, + }); + }, + getCallcbrCode: function () { + const app = getApp(); + this.setData({ + callcbrCode: `const c1 = new wx.cloud.Cloud({ + resourceEnv: ${app.globalData.env} +}) +await c1.init() +const r = await c1.callContainer({ + path: '/api/users', // 此处填入业务自定义路径, /api/users 为示例路径 + header: { + 'X-WX-SERVICE': 'express-test', // 填入业务服务名称,express-test 为示例服务 + }, + // 其余参数同 wx.request + method: 'GET', +})`, + }); + }, + getInitEnvCode: function () { + const app = getApp(); + this.setData({ + initEnvCode: `wx.cloud.init({ + env: ${app.globalData.env}, + traceUser: true, +});`, + }); + }, + getCreateCollectionCode: function () { + this.setData({ + callCreateCollectionCode: `const cloud = require('wx-server-sdk'); +cloud.init({ + env: cloud.DYNAMIC_CURRENT_ENV +}); +const db = cloud.database(); +// 创建集合云函数入口函数 +exports.main = async (event, context) => { + try { + // 创建集合 + await db.createCollection('sales'); + return { + success: true + }; + } catch (e) { + return { + success: true, + data: 'create collection success' + }; + } +};`, + }); + }, + getOpenIdCode: function () { + this.setData({ + callOpenIdCode: `const cloud = require('wx-server-sdk'); +cloud.init({ + env: cloud.DYNAMIC_CURRENT_ENV +}); +// 获取openId云函数入口函数 +exports.main = async (event, context) => { + // 获取基础信息 + const wxContext = cloud.getWXContext(); + return { + openid: wxContext.OPENID, + appid: wxContext.APPID, + unionid: wxContext.UNIONID, + }; +};`, + callFunctionCode: `wx.cloud.callFunction({ + name: 'quickstartFunctions', + data: { + type: 'getOpenId' + } +}).then((resp) => console.log(resp))`, + }); + }, + getMiniProgramCode: function () { + this.setData({ + callMiniProgramCode: `const cloud = require('wx-server-sdk'); +cloud.init({ + env: cloud.DYNAMIC_CURRENT_ENV +}); +// 获取小程序二维码云函数入口函数 +exports.main = async (event, context) => { + // 获取小程序二维码的buffer + const resp = await cloud.openapi.wxacode.get({ + path: 'pages/index/index' + }); + const { buffer } = resp; + // 将图片上传云存储空间 + const upload = await cloud.uploadFile({ + cloudPath: 'code.png', + fileContent: buffer + }); + return upload.fileID; +}; +`, + callFunctionCode: `wx.cloud.callFunction({ + name: 'quickstartFunctions', + data: { + type: 'getMiniProgramCode' + } +}).then((resp) => console.log(resp))`, + }); + }, + getUploadFileCode: function () { + this.setData({ + callUploadFileCode: `wx.chooseMedia({ +count: 1, +success: (chooseResult) => { + // 将图片上传至云存储空间 + wx.cloud + .uploadFile({ + // 指定上传到的云路径 + cloudPath: "my-photo.png", + // 指定要上传的文件的小程序临时文件路径 + filePath: chooseResult.tempFiles[0].tempFilePath, + }) + .then((res) => { + console.log(res) + }) + .catch((e) => { + console.log('e', e) + }); +} +});`, + }); + }, +}); diff --git a/miniprogram/pages/example/index.json b/miniprogram/pages/example/index.json new file mode 100644 index 0000000..3ea1434 --- /dev/null +++ b/miniprogram/pages/example/index.json @@ -0,0 +1,5 @@ +{ + "usingComponents": { + "cloud-tip-modal": "/components/cloudTipModal/index" + } +} \ No newline at end of file diff --git a/miniprogram/pages/example/index.wxml b/miniprogram/pages/example/index.wxml new file mode 100644 index 0000000..0e98204 --- /dev/null +++ b/miniprogram/pages/example/index.wxml @@ -0,0 +1,294 @@ + + + + 功能介绍 + 云函数无需维护鉴权机制及登录票据,仅一行代码即可获得。 + 云函数获取OpenId示例 + + + step 1 quickStartFunctions 云函数代码 + + + + step 2 小程序代码段 + + + + + + 运行示例 + {{ openId ? openId : 'OpenID将展示在这里' }} + + 清空 + + + + + + 功能介绍 + 可通过云函数免接口调用凭证,直接生成小程序码。 + 云函数获取小程序码示例 + + step 1 quickStartFunctions 云函数代码 + + + + step 2 小程序代码段 + + + + + + 运行示例 + 小程序码将展示在这里 + + + + 清空 + + + + + + + + 功能介绍 + 集合为常用数据库中表的概念。云开发数据库支持自动备份、无损回档,并且QPS高达3千+。 + 如何体验 + 已自动创建名为“sales”的体验合集,可打开“云开发控制台>数据库>记录列表”中找到该集合。 + + 云函数代码示例 + + + + + + + + + 功能介绍 + 体验查询记录能力,查询数据表中的销量数据。 + 销量数据将展示在这里 + 数据库操作示例 + 参考云函数 quickstartFunctions 示例代码 + + 地区销量统计 + + 地域 + 城市 + 销量 + 操作 + + + + {{item.region}} + {{item.city}} + + + + + + + + 查询记录 + 更新记录 + 新增记录 + + + + + + 新增销量记录 + + + + + + + + + + + + + + 功能介绍 + 多存储类型,仅需一个云函数即可完成上传。 + 文件上传示例 + + 小程序代码段 + + + + + 运行示例 + 上传的图片将展示在这里 + + + + + 清空 + + + + + + + 功能介绍 + 腾讯云开发提供 AI 对话能力,支持 Agent,大模型流式对话,可通过 Agent-UI 组件快速集成 AI 能力 + 集成 Agent-UI 组件指引 + + step 1 拷贝组件源码包 + 点击复制查看组件仓库地址 + step 2 将组件拷贝至小程序目录中 + + step 3 在页面 .json 配置文件中注册组件 + + + + step 4 在页面 .wxml 文件中引用组件 + + + + step 4 在页面 .js 文件中编写配置 + + + + + + + + + + 功能介绍 + 云托管 支持托管用任意语言和框架编写的容器化应用,为开发者提供高可用、自动弹性扩缩的云服务。 + 小程序调用云托管示例 + + step 1 前往云开发平台开通云托管 + step 2 新建容器型托管服务,等待部署完成 + + 此处可使用 Express 示例模板进行安装,此处示例命名为 express-test + + + + + step 3小程序端调用 + + + + 运行示例 + {{ callContainerResStr ? callContainerResStr : '云托管调用结果将展示在这里' }} + 清空 + + + + + + + AI 智能开发小程序 + 连接 AI 开发工具与 MCP 开发小程序 + + + + step + 1 + 打开扩展面板 + + + 在微信开发者工具中使用快捷键打开扩展面板: + + + + 注意:使用快捷键前需先点击编辑区,将焦点从预览区切换到编辑区。 + + + + + Windows/Linux: + Ctrl + Shift + X + + + macOS: + Shift + Command + X + + + + + step + 2 + 搜索并安装插件 + + + 在扩展商店中搜索: + + + 微信云开发 AI ToolKit + + + 复制 + + + + 找到插件后点击安装并启用 + + + + step + 3 + 打开 AI 开发功能 + + + 安装完成后,选中任意文件,点击左上角打开 AI 开发功能即可使用 + + + + + + + ✨ 场景示例 + + + 💡 智能代码生成与补全 + + + · 帮我创建一个商品列表页面,包含图片、标题、价格和加入购物车按钮 + + + + + + · 帮我完善这个函数,实现商品搜索功能 + + + + + + + + + 🔧 代码优化与重构建议 + + + · 优化这个页面的间隔,提升信息密度 + + + + + + · 完善云函数调用的错误处理代码 + + + + + + + + + + + + + diff --git a/miniprogram/pages/example/index.wxss b/miniprogram/pages/example/index.wxss new file mode 100644 index 0000000..d086e0b --- /dev/null +++ b/miniprogram/pages/example/index.wxss @@ -0,0 +1,393 @@ +page { + background-color: white; + padding-bottom: 50px; + font-family: PingFang SC; +} + +.page-container { + padding: 20rpx 40rpx; +} + +.tip { + font-size: 23rpx; + color: rgba(0, 0, 0, 0.5); + width: 90%; + text-align: center; + margin: 30rpx auto 0 auto; +} + +.top_tip { + font-size: 28rpx; + color: rgba(0, 0, 0, 0.5); + width: 90%; + text-align: left; + margin-top: 30rpx; + /* margin-left: 20rpx; */ +} + +.box_text { + background-color: white; + text-align: center; + padding: 300rpx 0; + margin-top: 30rpx; + color: rgba(0, 0, 0, 0.5); +} + +.button { + width: 300rpx; + text-align: center; + margin: 250rpx auto 0 auto; + height: 80rpx; + color: white; + border-radius: 5px; + line-height: 80rpx; + background-color: #07c160; +} + +.button_clear { + width: 300rpx; + text-align: center; + margin: 0 auto 0 auto; + height: 80rpx; + color: #07c160; + border-radius: 5px; + line-height: 80rpx; + background-color: rgba(0, 0, 0, 0.03); +} + +.line { + height: 1rpx; + width: 100%; + background-color: rgba(0, 0, 0, 0.1); +} + +.code_box { + text-align: center; + background-color: white; + margin-top: 30rpx; + padding: 17rpx; +} + +.code_box_title { + color: rgba(0, 0, 0, 0.5); + font-size: 26rpx; + margin-bottom: 20rpx; + text-align: left; +} + +.code_box_record { + display: flex; +} + +.code_box_record_title { + width: 33%; + font-size: 26rpx; + color: rgba(0, 0, 0, 0.5); + padding: 20rpx 0; +} + +.code_box_record_detail { + width: 25%; + font-size: 26rpx; + padding: 20rpx 0; + display: flex; + align-items: center; + justify-content: center; +} + +.title { + margin-top: 16px; + margin-bottom: 8px; + font-size: 36rpx; + font-weight: 500; + color: #000; +} + +.info { + margin-top: 12px; + font-size: 28rpx; + font-weight: 400; + color: rgba(0, 0, 0, 0.6); + line-height: 52rpx; +} + +.img { + /* margin-top: 16px; */ + width: 100%; +} + +.step-img { + margin-top: 20rpx; + border-radius: 12rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); +} + +.code_img { + width: 360rpx; + height: 360rpx; +} + +.block { + font-size: 16px; + line-height: 40px; +} + +.step-left { + color: #FFF; + background-color: #1aad19; + border: #1aad19 solid 1px; + padding: 0px 6px; +} + +.step-right { + color: #1aad19; + background-color: #FFF; + border: #1aad19 solid 1px; + padding: 0px 6px; + margin-right: 10px; +} + +.code_zone { + background-color: #0E190E; + color: rgba(255, 255, 255, 0.7); + border-radius: 12rpx; + padding: 16rpx 32rpx; + box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15); + position: relative; + margin-bottom: 24rpx; +} + +.btn-full { + height: 40px; + border-radius: 4px; + line-height: 40px; + color: #fff; + background-color: #1aad19; + text-align: center; + margin-bottom: 5px; +} + +.step-title { + line-height: 37px; + font-size: 16px; +} + +.step-text{ + font-size: 14px; + line-height: 24px; + padding: 10px 0px; + text-align: justify; +} + +.modal-mask { + position: fixed; left: 0; top: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.3); z-index: 1000; display: flex; align-items: center; justify-content: center; +} +.modal-content { + background: #fff; padding: 24px; border-radius: 8px; width: 80%; +} +.modal-title { font-size: 18px; margin-bottom: 16px; } +.modal-input { margin-bottom: 12px; border: 1px solid #eee; padding: 8px; border-radius: 4px; width: 100%; } +.modal-actions { display: flex; justify-content: flex-end; gap: 12px; } + +/* AI助手相关样式 */ +.notice-box { + background: linear-gradient(135deg, #fff8e6 0%, #fff3d9 100%); + border-left: 4rpx solid #ff9800; + border-radius: 8rpx; + padding: 20rpx 24rpx; + margin: 20rpx 0; + box-shadow: 0 2rpx 8rpx rgba(255, 152, 0, 0.1); +} + +.notice-text { + font-size: 26rpx; + color: rgba(0, 0, 0, 0.75); + line-height: 40rpx; +} + +.notice-highlight { + color: #ff6f00; + font-weight: 600; +} + +.shortcut-box { + background-color: rgba(0, 0, 0, 0.03); + border-radius: 8rpx; + padding: 24rpx; + margin: 20rpx 0; +} + +.shortcut-item { + display: flex; + align-items: center; + margin-bottom: 16rpx; +} + +.shortcut-item:last-child { + margin-bottom: 0; +} + +.shortcut-label { + font-size: 28rpx; + color: rgba(0, 0, 0, 0.7); + width: 240rpx; + flex-shrink: 0; +} + +.shortcut-key { + font-size: 28rpx; + color: #1aad19; + font-weight: 500; + background-color: rgba(26, 173, 25, 0.1); + padding: 4rpx 12rpx; + border-radius: 4rpx; + white-space: nowrap; +} + +.plugin-name-box { + background-color: rgba(26, 173, 25, 0.05); + border-left: 4rpx solid #1aad19; + padding: 20rpx; + margin: 20rpx 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.plugin-name { + font-size: 32rpx; + color: #1aad19; + font-weight: 500; + flex: 1; +} + +.copy-btn { + display: flex; + align-items: center; + padding: 6rpx 16rpx; + background-color: rgba(26, 173, 25, 0.1); + border: 1rpx solid rgba(26, 173, 25, 0.3); + border-radius: 8rpx; + margin-left: 20rpx; + transition: all 0.2s; +} + +.copy-btn:active { + background-color: rgba(26, 173, 25, 0.2); +} + +.copy-icon { + width: 24rpx; + height: 24rpx; + margin-right: 6rpx; + filter: invert(52%) sepia(73%) saturate(2034%) hue-rotate(90deg) brightness(92%) contrast(84%); +} + +.copy-text { + font-size: 24rpx; + color: #1aad19; + font-weight: 500; +} + +.divider { + height: 1rpx; + width: 100%; + background-color: rgba(0, 0, 0, 0.1); + margin: 40rpx 0; +} + +.feature-section { + margin-top: 20rpx; +} + +.feature-title { + font-size: 32rpx; + font-weight: 500; + color: rgba(0, 0, 0, 0.85); + margin-bottom: 20rpx; +} + +.feature-list { + padding-left: 0; +} + +.feature-item { + font-size: 28rpx; + color: rgba(0, 0, 0, 0.65); + line-height: 48rpx; + margin-bottom: 12rpx; +} + +/* 带示例的功能项 */ +.feature-item-with-examples { + margin-bottom: 20rpx; + padding: 20rpx; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 12rpx; +} + +.feature-item-with-examples:last-child { + margin-bottom: 0; +} + +.feature-main { + font-size: 30rpx; + font-weight: 500; + color: rgba(0, 0, 0, 0.85); + margin-bottom: 12rpx; + padding-bottom: 8rpx; + border-bottom: 1rpx solid rgba(0, 0, 0, 0.08); +} + +.feature-examples { + padding-left: 0; +} + +.feature-example { + font-size: 26rpx; + color: rgba(0, 0, 0, 0.55); + line-height: 44rpx; + margin-bottom: 8rpx; +} + +.feature-example-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4rpx; + padding: 8rpx 12rpx; +} + +.feature-example-text { + flex: 1; + font-size: 26rpx; + color: rgba(0, 0, 0, 0.55); + line-height: 40rpx; +} + +.feature-copy-btn { + padding: 12rpx; + border-radius: 8rpx; + display: flex; + align-items: center; + justify-content: center; + margin-left: 12rpx; + flex-shrink: 0; + transition: all 0.2s; +} + +.feature-copy-btn:active { + background-color: rgba(0, 0, 0, 0.08); +} + +.feature-copy-icon { + width: 32rpx; + height: 32rpx; + opacity: 0.5; + transition: opacity 0.2s; +} + +.feature-copy-btn:active .feature-copy-icon { + opacity: 0.8; +} \ No newline at end of file diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js new file mode 100644 index 0000000..427faeb --- /dev/null +++ b/miniprogram/pages/index/index.js @@ -0,0 +1,2 @@ +const api = require('../../utils/api') +Page({data:{activities:[],exhibitions:[],tags:[],exhibitionIndex:0,selectedTag:'',time:'available',loading:true},onLoad(){this.loadMeta()},onShow(){if(this.data.exhibitions.length)this.loadActivities()},onPullDownRefresh(){this.loadActivities().finally(wx.stopPullDownRefresh)},async loadMeta(){try{const data=await api.request('/api/activities/meta');this.setData({exhibitions:[{id:'',name:'全部会展'}].concat(data.exhibitions),tags:data.tags});await this.loadActivities()}catch(e){this.fail(e)}},async loadActivities(){this.setData({loading:true});const exhibition=this.data.exhibitions[this.data.exhibitionIndex];const qs=[`time=${this.data.time}`];if(exhibition&&exhibition.id)qs.push(`exhibitionId=${exhibition.id}`);if(this.data.selectedTag)qs.push(`tagId=${this.data.selectedTag}`);try{const activities=await api.request(`/api/activities?${qs.join('&')}`);this.setData({activities,loading:false})}catch(e){this.fail(e)}},changeExhibition(e){this.setData({exhibitionIndex:Number(e.detail.value)});this.loadActivities()},selectTag(e){this.setData({selectedTag:this.data.selectedTag===e.currentTarget.dataset.id?'':e.currentTarget.dataset.id});this.loadActivities()},setTime(e){this.setData({time:e.currentTarget.dataset.value});this.loadActivities()},open(e){wx.navigateTo({url:`/pages/activity/index?id=${e.currentTarget.dataset.id}`})},fail(e){this.setData({loading:false});wx.showToast({title:e.message||'加载失败',icon:'none'})}}) diff --git a/miniprogram/pages/index/index.json b/miniprogram/pages/index/index.json new file mode 100644 index 0000000..5f15720 --- /dev/null +++ b/miniprogram/pages/index/index.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "活动广场", + "enablePullDownRefresh": true, + "backgroundColor": "#f5f1ea" +} diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml new file mode 100644 index 0000000..8b60f08 --- /dev/null +++ b/miniprogram/pages/index/index.wxml @@ -0,0 +1 @@ +MEET · TALK · CONNECT下一场好对话,\n从这里开始发现会展期间值得加入的活动和有趣的人{{exhibitions[exhibitionIndex].name || '全部会展'}}⌄未开始可参加全部{{item.name}}正在寻找好活动…这个筛选下暂时没有活动CoffeeChat{{item.status==='active'?'正在进行':'即将开始'}} · {{item.startsAt}}{{item.title}}{{item.exhibition.name}} · {{item.location || item.exhibition.venue}}{{item.name}}{{item.participantCount}} 人参加 diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss new file mode 100644 index 0000000..25d37a7 --- /dev/null +++ b/miniprogram/pages/index/index.wxss @@ -0,0 +1 @@ +.hero{padding:36rpx 6rpx 42rpx}.eyebrow{font-size:20rpx;letter-spacing:5rpx;color:#8e6c55}.headline{white-space:pre-line;font-family:serif;font-weight:700;font-size:64rpx;line-height:1.13;margin:16rpx 0}.intro{color:#7a6e63;font-size:27rpx}.filters{display:flex;justify-content:space-between;align-items:center;margin-bottom:22rpx}.select{padding:18rpx 24rpx;background:#fffdf9;border-radius:999rpx;font-size:25rpx;max-width:320rpx;overflow:hidden;white-space:nowrap}.time-switch{display:flex;padding:5rpx;background:#e5ddd3;border-radius:999rpx}.time-switch view{padding:13rpx 20rpx;font-size:23rpx;color:#74665b;border-radius:999rpx}.time-switch .on{background:#2e5b47;color:#fff}.tags{white-space:nowrap;margin-bottom:30rpx}.tag-pill{display:inline-block;padding:13rpx 25rpx;border:1rpx solid #d9ccbd;border-radius:999rpx;margin-right:12rpx;color:#756558;font-size:24rpx}.tag-pill.active{background:#8b6248;border-color:#8b6248;color:white}.feed{display:flex;flex-direction:column;gap:26rpx}.activity{overflow:hidden}.cover{width:100%;height:370rpx;display:block}.placeholder{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#315c49,#9d7256);color:white;font:italic 52rpx serif;letter-spacing:3rpx}.body{padding:26rpx}.date{font-size:22rpx;color:#a15f3c}.title{font-size:36rpx;font-weight:700;margin:12rpx 0}.expo{color:#75685d;font-size:25rpx}.tag-row{display:flex;align-items:center;margin-top:22rpx}.people{font-size:23rpx;color:#7f7165;margin-left:auto} diff --git a/miniprogram/pages/login/index.js b/miniprogram/pages/login/index.js new file mode 100644 index 0000000..343d51e --- /dev/null +++ b/miniprogram/pages/login/index.js @@ -0,0 +1 @@ +Page({retry(){getApp().login().then(()=>wx.switchTab({url:'/pages/index/index'})).catch(e=>wx.showToast({title:e.message,icon:'none'}))}}) diff --git a/miniprogram/pages/login/index.json b/miniprogram/pages/login/index.json new file mode 100644 index 0000000..7ac7193 --- /dev/null +++ b/miniprogram/pages/login/index.json @@ -0,0 +1 @@ +{"navigationBarTitleText":"登录"} diff --git a/miniprogram/pages/login/index.wxml b/miniprogram/pages/login/index.wxml new file mode 100644 index 0000000..83dbc0e --- /dev/null +++ b/miniprogram/pages/login/index.wxml @@ -0,0 +1 @@ +CoffeeChat与值得认识的人,聊杯咖啡微信登录仅用于识别你的活动身份 diff --git a/miniprogram/pages/login/index.wxss b/miniprogram/pages/login/index.wxss new file mode 100644 index 0000000..f38bd57 --- /dev/null +++ b/miniprogram/pages/login/index.wxss @@ -0,0 +1 @@ +.login{padding:180rpx 60rpx;text-align:center}.mark{font:italic 34rpx serif;color:#9b7256}.title{font:700 58rpx/1.3 serif;margin:50rpx 0 24rpx}.login text{color:#817367}.login button{margin-top:70rpx} diff --git a/miniprogram/pages/profile/index.js b/miniprogram/pages/profile/index.js new file mode 100644 index 0000000..5481631 --- /dev/null +++ b/miniprogram/pages/profile/index.js @@ -0,0 +1 @@ +const api=require('../../utils/api');const app=getApp();Page({data:{user:null,editing:false,nickname:''},onShow(){app.ensureLogin().then(user=>this.setData({user,nickname:user.nickname})).catch(()=>{})},edit(){this.setData({editing:true})},input(e){this.setData({nickname:e.detail.value})},cancel(){this.setData({editing:false})},async save(){try{const user=await api.request('/api/auth/me',{method:'PATCH',data:{nickname:this.data.nickname,avatarUrl:this.data.user.avatarUrl||''}});wx.setStorageSync('user',user);app.globalData.user=user;this.setData({user,editing:false});wx.showToast({title:'已保存'})}catch(e){wx.showToast({title:e.message,icon:'none'})}},chooseAvatar(e){const avatarUrl=e.detail.avatarUrl;this.setData({'user.avatarUrl':avatarUrl});api.upload(avatarUrl).then(file=>this.setData({'user.avatarUrl':file.url}))}}) diff --git a/miniprogram/pages/profile/index.json b/miniprogram/pages/profile/index.json new file mode 100644 index 0000000..0d00efc --- /dev/null +++ b/miniprogram/pages/profile/index.json @@ -0,0 +1 @@ +{"navigationBarTitleText":"我的","backgroundColor":"#f5f1ea"} diff --git a/miniprogram/pages/profile/index.wxml b/miniprogram/pages/profile/index.wxml new file mode 100644 index 0000000..c672512 --- /dev/null +++ b/miniprogram/pages/profile/index.wxml @@ -0,0 +1 @@ +{{user.nickname || '微信用户'}}CoffeeChat 参与者ABOUT在会展里找到真正想聊的人。加入活动前写下你的目的,让现场的每次开场更自然。 diff --git a/miniprogram/pages/profile/index.wxss b/miniprogram/pages/profile/index.wxss new file mode 100644 index 0000000..d207961 --- /dev/null +++ b/miniprogram/pages/profile/index.wxss @@ -0,0 +1 @@ +.profile{margin-top:30rpx;padding:36rpx;display:flex;gap:28rpx;align-items:center}.avatar-btn{padding:0;width:132rpx;height:132rpx;border-radius:50%;overflow:hidden;flex:none}.avatar-btn image{width:100%;height:100%}.name{font-size:40rpx;font-weight:700;margin-bottom:8rpx}.small{font-size:24rpx;padding:8rpx 22rpx;margin:18rpx 8rpx 0 0;display:inline-block;line-height:1.8}.edit input{background:#f2ece5;border-radius:12rpx;padding:16rpx}.about{margin:70rpx 10rpx}.about view{font:700 46rpx/1.3 serif;margin:16rpx 0}.about .muted{line-height:1.7}.eyebrow{font-size:20rpx;letter-spacing:4rpx;color:#9b7256} diff --git a/miniprogram/sitemap.json b/miniprogram/sitemap.json new file mode 100644 index 0000000..27b2b26 --- /dev/null +++ b/miniprogram/sitemap.json @@ -0,0 +1,7 @@ +{ + "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", + "rules": [{ + "action": "allow", + "page": "*" + }] +} \ No newline at end of file diff --git a/miniprogram/utils/api.js b/miniprogram/utils/api.js new file mode 100644 index 0000000..bd2ac2e --- /dev/null +++ b/miniprogram/utils/api.js @@ -0,0 +1,4 @@ +const { apiBaseUrl } = require('../config') +function request(path, options = {}) { return new Promise((resolve, reject) => wx.request({ url: apiBaseUrl + path, method: options.method || 'GET', data: options.data, header: { 'content-type': 'application/json', Authorization: `Bearer ${wx.getStorageSync('token') || ''}` }, success: (res) => res.statusCode >= 200 && res.statusCode < 300 ? resolve(res.data) : reject(new Error(res.data && res.data.error || '网络请求失败')), fail: reject })) } +function upload(filePath) { return new Promise((resolve, reject) => wx.uploadFile({ url: apiBaseUrl + '/api/uploads', filePath, name: 'files', header: { Authorization: `Bearer ${wx.getStorageSync('token') || ''}` }, success: (res) => { const data = JSON.parse(res.data || '{}'); res.statusCode < 300 ? resolve(data.files[0]) : reject(new Error(data.error || '上传失败')) }, fail: reject })) } +module.exports = { request, upload, apiBaseUrl } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3c766e5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1318 @@ +{ + "name": "coffeechat", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "coffeechat", + "workspaces": [ + "server" + ] + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/coffeechat-server": { + "resolved": "server", + "link": true + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "server": { + "name": "coffeechat-server", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "express": "^5.1.0", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", + "pg": "^8.16.3", + "zod": "^4.1.5" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7dca534 --- /dev/null +++ b/package.json @@ -0,0 +1,2 @@ +{"name":"coffeechat","private":true,"workspaces":["server"],"scripts":{"dev":"npm --workspace server run dev","start":"npm --workspace server start","test":"npm --workspace server test","check":"npm --workspace server run check"}} + diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..be0b0d3 --- /dev/null +++ b/project.config.json @@ -0,0 +1,85 @@ +{ + "miniprogramRoot": "miniprogram/", + "cloudfunctionRoot": "cloudfunctions/", + "setting": { + "urlCheck": true, + "es6": true, + "enhance": true, + "postcss": true, + "preloadBackgroundData": false, + "minified": true, + "newFeature": true, + "coverView": true, + "nodeModules": false, + "autoAudits": false, + "showShadowRootInWxmlPanel": true, + "scopeDataCheck": false, + "uglifyFileName": false, + "checkInvalidKey": true, + "checkSiteMap": true, + "uploadWithSourceMap": true, + "compileHotReLoad": false, + "useMultiFrameRuntime": true, + "useApiHook": true, + "useApiHostProcess": true, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + }, + "enableEngineNative": false, + "useIsolateContext": true, + "useCompilerModule": true, + "userConfirmedUseCompilerModuleSwitch": false, + "userConfirmedBundleSwitch": false, + "packNpmManually": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "compileWorklet": false, + "minifyWXML": true, + "localPlugins": false, + "disableUseStrict": false, + "useCompilerPlugins": false, + "condition": false, + "swc": false, + "disableSWC": true + }, + "appid": "wx386640f47786131f", + "projectname": "coffeechat", + "libVersion": "2.20.1", + "cloudfunctionTemplateRoot": "cloudfunctionTemplate/", + "condition": { + "search": { + "list": [] + }, + "conversation": { + "list": [] + }, + "plugin": { + "list": [] + }, + "game": { + "list": [] + }, + "miniprogram": { + "list": [ + { + "id": -1, + "name": "db guide", + "pathName": "pages/databaseGuide/databaseGuide" + } + ] + } + }, + "compileType": "miniprogram", + "srcMiniprogramRoot": "miniprogram/", + "packOptions": { + "ignore": [], + "include": [] + }, + "editorSetting": { + "tabIndent": "insertSpaces", + "tabSize": 2 + }, + "simulatorPluginLibVersion": {} +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..78f01ac --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package*.json ./ +COPY server/package.json server/package.json +RUN npm ci --omit=dev --workspace server + +FROM node:22-alpine +ENV NODE_ENV=production +WORKDIR /app +RUN apk add --no-cache curl +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY server/package.json server/package.json +COPY server/src server/src +COPY server/public server/public +RUN mkdir -p /app/uploads && chown -R node:node /app +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD curl -fsS http://localhost:3000/health || exit 1 +CMD ["node", "server/src/index.js"] diff --git a/server/migrations/001_init.sql b/server/migrations/001_init.sql new file mode 100644 index 0000000..5550557 --- /dev/null +++ b/server/migrations/001_init.sql @@ -0,0 +1,48 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), openid text UNIQUE NOT NULL, unionid text, + nickname text NOT NULL DEFAULT '微信用户', avatar_url text, + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS exhibitions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, venue text NOT NULL, + address text NOT NULL DEFAULT '', starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL, + description text NOT NULL DEFAULT '', cover_url text, created_at timestamptz NOT NULL DEFAULT now(), + CHECK (ends_at > starts_at) +); +CREATE TABLE IF NOT EXISTS tags ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text UNIQUE NOT NULL, color text NOT NULL DEFAULT '#7C5CFC' +); +CREATE TABLE IF NOT EXISTS activities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), exhibition_id uuid NOT NULL REFERENCES exhibitions(id) ON DELETE CASCADE, + title text NOT NULL, description text NOT NULL DEFAULT '', location text NOT NULL DEFAULT '', + starts_at timestamptz NOT NULL, ends_at timestamptz, capacity integer CHECK (capacity IS NULL OR capacity > 0), + published boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (ends_at IS NULL OR ends_at > starts_at) +); +CREATE TABLE IF NOT EXISTS activity_images ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + url text NOT NULL, sort_order integer NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS activity_tags ( + activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (activity_id, tag_id) +); +CREATE TABLE IF NOT EXISTS participations ( + activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, purpose text NOT NULL DEFAULT '', + joined_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (activity_id, user_id) +); +DO $$ BEGIN CREATE TYPE group_review_status AS ENUM ('pending', 'approved', 'rejected'); EXCEPTION WHEN duplicate_object THEN null; END $$; +CREATE TABLE IF NOT EXISTS activity_groups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + submitted_by uuid REFERENCES users(id) ON DELETE SET NULL, name text NOT NULL, qr_image_url text NOT NULL, + note text NOT NULL DEFAULT '', status group_review_status NOT NULL DEFAULT 'pending', review_note text NOT NULL DEFAULT '', + reviewed_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_activities_exhibition_start ON activities(exhibition_id, starts_at); +CREATE INDEX IF NOT EXISTS idx_activities_published_start ON activities(published, starts_at); +CREATE INDEX IF NOT EXISTS idx_participations_activity ON participations(activity_id, joined_at); +CREATE INDEX IF NOT EXISTS idx_groups_review ON activity_groups(status, created_at); + diff --git a/server/migrations/002_seed.sql b/server/migrations/002_seed.sql new file mode 100644 index 0000000..1b36a00 --- /dev/null +++ b/server/migrations/002_seed.sql @@ -0,0 +1,4 @@ +INSERT INTO tags (name, color) VALUES + ('创业', '#FF7A59'), ('AI', '#7C5CFC'), ('投资', '#2FA36B'), ('出海', '#1687A7'), ('设计', '#E65C9C') +ON CONFLICT (name) DO NOTHING; + diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..5ad3429 --- /dev/null +++ b/server/package.json @@ -0,0 +1,2 @@ +{"name":"coffeechat-server","version":"1.0.0","private":true,"type":"module","engines":{"node":">=20"},"scripts":{"dev":"node --watch src/index.js","start":"node src/index.js","test":"node --test","check":"node --check src/index.js && node --check src/app.js"},"dependencies":{"cors":"^2.8.5","express":"^5.1.0","helmet":"^8.1.0","jsonwebtoken":"^9.0.2","multer":"^2.0.2","pg":"^8.16.3","zod":"^4.1.5"}} + diff --git a/server/public/admin/app.js b/server/public/admin/app.js new file mode 100644 index 0000000..c3bb6ca --- /dev/null +++ b/server/public/admin/app.js @@ -0,0 +1,18 @@ +const state={token:localStorage.adminToken||'',data:null}; +const $=(s)=>document.querySelector(s); const esc=(v='')=>String(v).replace(/[&<>"']/g,(c)=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +function toast(message){const el=$('#toast');el.textContent=message;el.style.display='block';setTimeout(()=>el.style.display='none',2200)} +async function api(path,options={}){const response=await fetch(`/api/admin${path}`,{...options,headers:{'Content-Type':'application/json','x-admin-token':state.token,...options.headers}});const data=response.status===204?null:await response.json();if(!response.ok)throw new Error(data.error||'请求失败');return data} +async function uploadImages(files){if(!files.length)return[];if(files.length>9)throw new Error('最多上传 9 张图片');const body=new FormData();Array.from(files).forEach(file=>body.append('files',file));const response=await fetch('/api/uploads/admin',{method:'POST',headers:{'x-admin-token':state.token},body});const data=await response.json();if(!response.ok)throw new Error(data.error||'图片上传失败');return data.files.map(x=>x.url)} +function date(v){return new Date(v).toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})} +async function load(){try{state.data=await api('/dashboard');localStorage.adminToken=state.token;$('#login').hidden=true;$('#app').hidden=false;render()}catch(e){$('#login').hidden=false;$('#app').hidden=true;toast(e.message)}} +function render(){const d=state.data;$('#exhibitionCount').textContent=d.exhibitions.length;$('#activityCount').textContent=d.activities.length;$('#pendingCount').textContent=d.groups.filter(g=>g.status==='pending').length; + $('#exhibitionSelect').innerHTML=d.exhibitions.map(x=>``).join('');$('#tagChecks').innerHTML=d.tags.map(t=>``).join(''); + $('#activityList').innerHTML=d.activities.map(a=>`

${esc(a.title)}

${esc(a.exhibitionName)} · ${date(a.startsAt)} · ${a.published?'已发布':'草稿'}

`).join('')||'

还没有活动

'; + $('#exhibitionList').innerHTML=d.exhibitions.map(e=>`

${esc(e.name)}

${esc(e.venue)} · ${date(e.startsAt)} 至 ${date(e.endsAt)}

`).join('')||'

还没有会展

'; + $('#groupList').innerHTML=d.groups.map(g=>`

${esc(g.name)} · ${esc(g.activityTitle)}

${esc(g.submitterName||'未知用户')} · ${esc(g.note)} · ${g.status}

${g.status==='pending'?`
`:''}
`).join('')||'

还没有群资料

';} +$('#loginForm').onsubmit=(e)=>{e.preventDefault();state.token=$('#token').value;load()};$('#logout').onclick=()=>{localStorage.removeItem('adminToken');location.reload()}; +document.addEventListener('click',async(e)=>{const tab=e.target.dataset.tab;if(tab){document.querySelectorAll('nav button').forEach(x=>x.classList.toggle('active',x===e.target));document.querySelectorAll('.panel').forEach(x=>x.hidden=x.id!==tab)}if(e.target.dataset.open)document.getElementById(e.target.dataset.open).showModal();if(e.target.classList.contains('close'))e.target.closest('dialog').close(); + try{if(e.target.dataset.publish){await api(`/activities/${e.target.dataset.publish}/publish`,{method:'PATCH',body:JSON.stringify({published:e.target.dataset.value==='true'})});await load()}if(e.target.dataset.review){await api(`/groups/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.dataset.status})});await load()}}catch(err){toast(err.message)}}); +$('#exhibitionForm').onsubmit=async(e)=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{await api('/exhibitions',{method:'POST',body:JSON.stringify(data)});e.target.closest('dialog').close();e.target.reset();await load();toast('会展已创建')}catch(err){toast(err.message)}}; +$('#activityForm').onsubmit=async(e)=>{e.preventDefault();const form=new FormData(e.target);const data=Object.fromEntries(form);data.published=form.has('published');data.tagIds=form.getAll('tagIds');const typed=(data.imageUrls||'').split('\n').map(x=>x.trim()).filter(Boolean);data.endsAt=data.endsAt||null;try{const uploaded=await uploadImages(e.target.elements.images.files);data.imageUrls=uploaded.concat(typed).slice(0,9);delete data.images;await api('/activities',{method:'POST',body:JSON.stringify(data)});e.target.closest('dialog').close();e.target.reset();await load();toast('活动已创建')}catch(err){toast(err.message)}}; +if(state.token)load(); diff --git a/server/public/admin/index.html b/server/public/admin/index.html new file mode 100644 index 0000000..f4a4448 --- /dev/null +++ b/server/public/admin/index.html @@ -0,0 +1,15 @@ +CoffeeChat 管理后台 +
+
COFFEECHAT CONSOLE

让每一次会面都有意义

+ + +
+

新建会展

+

新建活动

+
diff --git a/server/public/admin/style.css b/server/public/admin/style.css new file mode 100644 index 0000000..11ea468 --- /dev/null +++ b/server/public/admin/style.css @@ -0,0 +1,2 @@ +:root{font-family:Inter,"PingFang SC",sans-serif;color:#241f1b;background:#f4efe8;font-size:16px}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 0,#eadbca,transparent 38%),#f4efe8}main{max-width:1100px;margin:auto;padding:40px 24px 80px}header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:34px}h1{font-family:Georgia,"Songti SC",serif;font-size:42px;line-height:1.1;margin:8px 0}.eyebrow{font-size:12px;letter-spacing:3px;color:#8a6750}button{border:0;border-radius:999px;padding:12px 22px;background:#2e5b47;color:white;font-weight:600;cursor:pointer}.ghost{background:transparent;color:#684c3a;border:1px solid #bca895}.card,.panel{background:#fffdf9;border:1px solid #e3d8ca;border-radius:24px;box-shadow:0 16px 50px #604a3420}.login{max-width:480px;padding:34px}.login form{display:flex;gap:10px}.login input{flex:1}input,textarea,select{width:100%;padding:12px 14px;border:1px solid #d9ccbd;border-radius:12px;background:#fff;font:inherit}textarea{resize:vertical}label{display:block;margin:14px 0;color:#705c4c;font-size:14px}label input,label textarea,label select{margin-top:7px;color:#241f1b}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:26px}.stats article{padding:24px;border-radius:20px;background:#2e5b47;color:#fff}.stats b{font:36px Georgia}.stats span{display:block;opacity:.75;margin-top:5px}nav{display:flex;gap:8px;margin-bottom:14px}nav button{background:transparent;color:#705c4c}nav button.active{background:#2e5b47;color:#fff}.panel{padding:28px}.section-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.section-title h2{margin:0}.section-title p{margin:6px 0;color:#8f7d6e}.list{display:grid;gap:10px}.row{display:flex;align-items:center;justify-content:space-between;padding:16px;border-radius:14px;background:#f8f4ee}.row h3{font-size:16px;margin:0 0 5px}.row p{margin:0;color:#847365;font-size:13px}.row-actions{display:flex;gap:8px}.row-actions button{padding:8px 13px;font-size:12px}.reject{background:#a74b3f}.qr{width:64px;height:64px;object-fit:cover;border-radius:10px;margin-right:14px}.group-main{display:flex;align-items:center}dialog{border:0;border-radius:24px;padding:0;box-shadow:0 24px 90px #241f1b55;width:min(620px,calc(100% - 32px))}dialog::backdrop{background:#241f1b88}dialog form{padding:32px;position:relative}.close{position:absolute;right:20px;top:18px;background:transparent;color:#705c4c;font-size:24px;padding:5px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.checks{display:flex;flex-wrap:wrap;gap:8px}.checks label,.inline{display:flex;align-items:center;gap:6px}.checks input,.inline input{width:auto;margin:0}#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#241f1b;color:#fff;padding:12px 20px;border-radius:99px;display:none}@media(max-width:620px){h1{font-size:32px}.stats{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.section-title,.row{align-items:flex-start;gap:12px}.row{flex-direction:column}.login form{flex-direction:column}} + diff --git a/server/src/app.js b/server/src/app.js new file mode 100644 index 0000000..b07b250 --- /dev/null +++ b/server/src/app.js @@ -0,0 +1,28 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createAuth } from './lib/auth.js'; +import { authRoutes } from './routes/auth.js'; +import { catalogRoutes } from './routes/catalog.js'; +import { participationRoutes } from './routes/participations.js'; +import { adminRoutes } from './routes/admin.js'; +import { uploadRoutes } from './routes/uploads.js'; + +export function createApp({ db, config }) { + fs.mkdirSync(config.uploadDir, { recursive: true }); + const app = express(); const auth = createAuth(config); + app.use(helmet({ contentSecurityPolicy: false })); app.use(cors()); app.use(express.json({ limit: '1mb' })); + app.get('/health', async (_req, res) => { await db.query('SELECT 1'); res.json({ ok: true }); }); + app.use('/uploads', express.static(config.uploadDir, { maxAge: '7d' })); + app.use('/admin', express.static(new URL('../public/admin', import.meta.url).pathname)); + app.use('/api/auth', authRoutes({ db, config, auth })); + app.use('/api/activities', catalogRoutes({ db })); + app.use('/api/activities/:activityId/participations', participationRoutes({ db, auth })); + app.use('/api/uploads', uploadRoutes({ config, auth })); + app.use('/api/admin', adminRoutes({ db, auth })); + app.use((error, _req, res, _next) => { console.error(error); res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 500).json({ error: '服务器暂时无法处理请求' }); }); + return app; +} + diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..0f49937 --- /dev/null +++ b/server/src/config.js @@ -0,0 +1,22 @@ +const requiredInProduction = ['DATABASE_URL', 'JWT_SECRET', 'WECHAT_APP_ID', 'WECHAT_APP_SECRET', 'ADMIN_TOKEN']; + +export function loadConfig(env = process.env) { + const production = env.NODE_ENV === 'production'; + if (production) { + const missing = requiredInProduction.filter((key) => !env[key]); + if (missing.length) throw new Error(`Missing environment variables: ${missing.join(', ')}`); + } + return { + production, + port: Number(env.PORT || 3000), + databaseUrl: env.DATABASE_URL || 'postgres://coffeechat:coffeechat@localhost:5432/coffeechat', + jwtSecret: env.JWT_SECRET || 'development-only-secret-change-me', + wechatAppId: env.WECHAT_APP_ID || '', + wechatAppSecret: env.WECHAT_APP_SECRET || '', + adminToken: env.ADMIN_TOKEN || 'development-admin-token', + publicBaseUrl: (env.PUBLIC_BASE_URL || `http://localhost:${env.PORT || 3000}`).replace(/\/$/, ''), + uploadDir: env.UPLOAD_DIR || new URL('../uploads', import.meta.url).pathname, + allowDevLogin: !production && env.ALLOW_DEV_LOGIN !== 'false' + }; +} + diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..987bd6d --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,15 @@ +import pg from 'pg'; + +export function createDb(connectionString) { + const pool = new pg.Pool({ connectionString }); + return { + query: (query, params) => pool.query(query, params), + async transaction(work) { + const client = await pool.connect(); + try { await client.query('BEGIN'); const result = await work(client); await client.query('COMMIT'); return result; } + catch (error) { await client.query('ROLLBACK'); throw error; } + finally { client.release(); } + }, + close: () => pool.end() + }; +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..269f18d --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,9 @@ +import { loadConfig } from './config.js'; +import { createDb } from './db.js'; +import { createApp } from './app.js'; + +const config = loadConfig(); const db = createDb(config.databaseUrl); const app = createApp({ db, config }); +const server = app.listen(config.port, '0.0.0.0', () => console.log(`CoffeeChat API listening on ${config.port}`)); +async function shutdown() { server.close(); await db.close(); } +process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); + diff --git a/server/src/lib/activities.js b/server/src/lib/activities.js new file mode 100644 index 0000000..703b052 --- /dev/null +++ b/server/src/lib/activities.js @@ -0,0 +1,16 @@ +export function activityStatus(startsAt, endsAt, now = new Date()) { + const start = new Date(startsAt); + const end = endsAt ? new Date(endsAt) : start; + if (start > now) return 'upcoming'; + if (end >= now) return 'active'; + return 'ended'; +} + +export function normalizeActivity(row, now = new Date()) { + return { + ...row, + status: activityStatus(row.startsAt, row.endsAt, now), + images: row.images || [], tags: row.tags || [], participantCount: Number(row.participantCount || 0) + }; +} + diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js new file mode 100644 index 0000000..0ee811d --- /dev/null +++ b/server/src/lib/auth.js @@ -0,0 +1,18 @@ +import jwt from 'jsonwebtoken'; + +export function createAuth(config) { + const sign = (user) => jwt.sign({ sub: user.id, openid: user.openid }, config.jwtSecret, { expiresIn: '30d' }); + function userRequired(req, res, next) { + const token = req.headers.authorization?.replace(/^Bearer\s+/i, ''); + if (!token) return res.status(401).json({ error: '请先登录' }); + try { req.user = jwt.verify(token, config.jwtSecret); next(); } + catch { res.status(401).json({ error: '登录已过期,请重新登录' }); } + } + function adminRequired(req, res, next) { + const token = req.headers['x-admin-token']; + if (!token || token !== config.adminToken) return res.status(401).json({ error: '管理员凭据无效' }); + next(); + } + return { sign, userRequired, adminRequired }; +} + diff --git a/server/src/lib/validation.js b/server/src/lib/validation.js new file mode 100644 index 0000000..fc9ff48 --- /dev/null +++ b/server/src/lib/validation.js @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ code: z.string().min(1), nickname: z.string().trim().max(40).optional(), avatarUrl: z.string().url().optional().or(z.literal('')) }); +export const profileSchema = z.object({ nickname: z.string().trim().min(1).max(40), avatarUrl: z.string().url().optional().or(z.literal('')) }); +export const purposeSchema = z.object({ purpose: z.string().trim().max(300).default('') }); +export const groupSchema = z.object({ name: z.string().trim().min(1).max(80), qrImageUrl: z.string().url(), note: z.string().trim().max(300).default('') }); +export const exhibitionSchema = z.object({ + name: z.string().trim().min(1).max(120), venue: z.string().trim().min(1).max(120), address: z.string().trim().max(300).default(''), + startsAt: z.coerce.date(), endsAt: z.coerce.date(), description: z.string().trim().max(5000).default(''), coverUrl: z.string().url().optional().or(z.literal('')) +}).refine((v) => v.endsAt > v.startsAt, { message: '结束时间必须晚于开始时间' }); +export const activitySchema = z.object({ + exhibitionId: z.string().uuid(), title: z.string().trim().min(1).max(120), description: z.string().trim().max(10000).default(''), + location: z.string().trim().max(200).default(''), startsAt: z.coerce.date(), endsAt: z.coerce.date().nullable().optional(), + capacity: z.coerce.number().int().positive().nullable().optional(), published: z.boolean().default(false), + imageUrls: z.array(z.string().url()).max(9).default([]), tagIds: z.array(z.string().uuid()).max(10).default([]) +}).refine((v) => !v.endsAt || v.endsAt > v.startsAt, { message: '结束时间必须晚于开始时间' }); + +export function parse(schema, req, res) { + const result = schema.safeParse(req.body); + if (!result.success) { + res.status(400).json({ error: result.error.issues[0]?.message || '请求参数错误' }); + return null; + } + return result.data; +} + diff --git a/server/src/lib/wechat.js b/server/src/lib/wechat.js new file mode 100644 index 0000000..7f60640 --- /dev/null +++ b/server/src/lib/wechat.js @@ -0,0 +1,12 @@ +export async function exchangeCode({ code, appId, appSecret, fetchImpl = fetch }) { + const url = new URL('https://api.weixin.qq.com/sns/jscode2session'); + url.searchParams.set('appid', appId); + url.searchParams.set('secret', appSecret); + url.searchParams.set('js_code', code); + url.searchParams.set('grant_type', 'authorization_code'); + const response = await fetchImpl(url); + const data = await response.json(); + if (!response.ok || data.errcode || !data.openid) throw new Error(data.errmsg || '微信登录失败'); + return data; +} + diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js new file mode 100644 index 0000000..96c02d6 --- /dev/null +++ b/server/src/routes/admin.js @@ -0,0 +1,54 @@ +import { Router } from 'express'; +import { activitySchema, exhibitionSchema, parse } from '../lib/validation.js'; + +export function adminRoutes({ db, auth }) { + const router = Router(); + router.use(auth.adminRequired); + router.get('/dashboard', async (_req, res) => { + const [exhibitions, activities, groups, tags] = await Promise.all([ + db.query('SELECT id,name,venue,starts_at AS "startsAt",ends_at AS "endsAt" FROM exhibitions ORDER BY starts_at DESC'), + db.query(`SELECT a.id,a.title,a.starts_at AS "startsAt",a.published,e.name AS "exhibitionName" FROM activities a JOIN exhibitions e ON e.id=a.exhibition_id ORDER BY a.starts_at DESC`), + db.query(`SELECT g.id,g.name,g.qr_image_url AS "qrImageUrl",g.note,g.status,g.created_at AS "createdAt",a.title AS "activityTitle",u.nickname AS "submitterName" FROM activity_groups g JOIN activities a ON a.id=g.activity_id LEFT JOIN users u ON u.id=g.submitted_by ORDER BY g.created_at DESC`), + db.query('SELECT id,name,color FROM tags ORDER BY name') + ]); + res.json({ exhibitions: exhibitions.rows, activities: activities.rows, groups: groups.rows, tags: tags.rows }); + }); + router.post('/exhibitions', async (req, res) => { + const body = parse(exhibitionSchema, req, res); if (!body) return; + const { rows: [item] } = await db.query( + `INSERT INTO exhibitions(name,venue,address,starts_at,ends_at,description,cover_url) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + [body.name, body.venue, body.address, body.startsAt, body.endsAt, body.description, body.coverUrl || null] + ); + res.status(201).json(item); + }); + router.post('/activities', async (req, res) => { + const body = parse(activitySchema, req, res); if (!body) return; + const item = await db.transaction(async (client) => { + const { rows: [created] } = await client.query( + `INSERT INTO activities(exhibition_id,title,description,location,starts_at,ends_at,capacity,published) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, + [body.exhibitionId, body.title, body.description, body.location, body.startsAt, body.endsAt || null, body.capacity || null, body.published] + ); + for (const [index, url] of body.imageUrls.entries()) await client.query('INSERT INTO activity_images(activity_id,url,sort_order) VALUES($1,$2,$3)', [created.id, url, index]); + for (const tagId of body.tagIds) await client.query('INSERT INTO activity_tags(activity_id,tag_id) VALUES($1,$2)', [created.id, tagId]); + return created; + }); + res.status(201).json(item); + }); + router.patch('/activities/:id/publish', async (req, res) => { + const published = req.body.published === true; + const { rows: [item] } = await db.query('UPDATE activities SET published=$2,updated_at=now() WHERE id=$1 RETURNING id,published', [req.params.id, published]); + if (!item) return res.status(404).json({ error: '活动不存在' }); + res.json(item); + }); + router.patch('/groups/:id/review', async (req, res) => { + if (!['approved', 'rejected'].includes(req.body.status)) return res.status(400).json({ error: '审核状态无效' }); + const { rows: [item] } = await db.query( + `UPDATE activity_groups SET status=$2,review_note=$3,reviewed_at=now() WHERE id=$1 RETURNING id,status`, + [req.params.id, req.body.status, String(req.body.reviewNote || '').slice(0, 300)] + ); + if (!item) return res.status(404).json({ error: '群资料不存在' }); + res.json(item); + }); + return router; +} + diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..abc1847 --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,39 @@ +import { Router } from 'express'; +import { exchangeCode } from '../lib/wechat.js'; +import { loginSchema, parse, profileSchema } from '../lib/validation.js'; + +export function authRoutes({ db, config, auth }) { + const router = Router(); + router.post('/wechat', async (req, res) => { + const body = parse(loginSchema, req, res); if (!body) return; + try { + let session; + if (config.allowDevLogin && body.code.startsWith('dev:')) session = { openid: body.code, unionid: null }; + else session = await exchangeCode({ code: body.code, appId: config.wechatAppId, appSecret: config.wechatAppSecret }); + const { rows: [user] } = await db.query( + `INSERT INTO users (openid, unionid, nickname, avatar_url) VALUES ($1,$2,$3,$4) + ON CONFLICT (openid) DO UPDATE SET unionid=COALESCE(EXCLUDED.unionid,users.unionid), + nickname=CASE WHEN EXCLUDED.nickname <> '微信用户' THEN EXCLUDED.nickname ELSE users.nickname END, + avatar_url=COALESCE(NULLIF(EXCLUDED.avatar_url,''),users.avatar_url), updated_at=now() + RETURNING id, nickname, avatar_url AS "avatarUrl", created_at AS "createdAt"`, + [session.openid, session.unionid || null, body.nickname || '微信用户', body.avatarUrl || null] + ); + res.json({ token: auth.sign({ id: user.id, openid: session.openid }), user }); + } catch (error) { res.status(401).json({ error: error.message }); } + }); + router.get('/me', auth.userRequired, async (req, res) => { + const { rows: [user] } = await db.query('SELECT id,nickname,avatar_url AS "avatarUrl",created_at AS "createdAt" FROM users WHERE id=$1', [req.user.sub]); + if (!user) return res.status(404).json({ error: '用户不存在' }); + res.json(user); + }); + router.patch('/me', auth.userRequired, async (req, res) => { + const body = parse(profileSchema, req, res); if (!body) return; + const { rows: [user] } = await db.query( + 'UPDATE users SET nickname=$2,avatar_url=$3,updated_at=now() WHERE id=$1 RETURNING id,nickname,avatar_url AS "avatarUrl"', + [req.user.sub, body.nickname, body.avatarUrl || null] + ); + res.json(user); + }); + return router; +} + diff --git a/server/src/routes/catalog.js b/server/src/routes/catalog.js new file mode 100644 index 0000000..19d43a4 --- /dev/null +++ b/server/src/routes/catalog.js @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import { normalizeActivity } from '../lib/activities.js'; + +const activitySelect = ` + SELECT a.id,a.title,a.description,a.location,a.starts_at AS "startsAt",a.ends_at AS "endsAt",a.capacity, + json_build_object('id',e.id,'name',e.name,'venue',e.venue,'address',e.address,'startsAt',e.starts_at,'endsAt',e.ends_at,'description',e.description,'coverUrl',e.cover_url) exhibition, + COALESCE((SELECT json_agg(json_build_object('id',i.id,'url',i.url) ORDER BY i.sort_order) FROM activity_images i WHERE i.activity_id=a.id),'[]') images, + COALESCE((SELECT json_agg(json_build_object('id',t.id,'name',t.name,'color',t.color)) FROM activity_tags at JOIN tags t ON t.id=at.tag_id WHERE at.activity_id=a.id),'[]') tags, + (SELECT count(*) FROM participations p WHERE p.activity_id=a.id) AS "participantCount" + FROM activities a JOIN exhibitions e ON e.id=a.exhibition_id`; + +export function catalogRoutes({ db }) { + const router = Router(); + router.get('/meta', async (_req, res) => { + const [exhibitions, tags] = await Promise.all([ + db.query('SELECT id,name,venue,address,starts_at AS "startsAt",ends_at AS "endsAt",description,cover_url AS "coverUrl" FROM exhibitions ORDER BY starts_at DESC'), + db.query('SELECT id,name,color FROM tags ORDER BY name') + ]); + res.json({ exhibitions: exhibitions.rows, tags: tags.rows }); + }); + router.get('/', async (req, res) => { + const values = []; const where = ['a.published=true']; + if (req.query.exhibitionId) { values.push(req.query.exhibitionId); where.push(`a.exhibition_id=$${values.length}`); } + if (req.query.tagId) { values.push(req.query.tagId); where.push(`EXISTS (SELECT 1 FROM activity_tags x WHERE x.activity_id=a.id AND x.tag_id=$${values.length})`); } + if (req.query.time === 'upcoming') where.push('a.starts_at > now()'); + if (req.query.time === 'available') where.push('COALESCE(a.ends_at,a.starts_at) >= now()'); + const { rows } = await db.query(`${activitySelect} WHERE ${where.join(' AND ')} ORDER BY a.starts_at ASC`, values); + res.json(rows.map((row) => normalizeActivity(row))); + }); + router.get('/:id', async (req, res) => { + const { rows: [row] } = await db.query(`${activitySelect} WHERE a.id=$1 AND a.published=true`, [req.params.id]); + if (!row) return res.status(404).json({ error: '活动不存在' }); + const participants = await db.query( + `SELECT u.id,u.nickname,u.avatar_url AS "avatarUrl",p.purpose,p.joined_at AS "joinedAt" + FROM participations p JOIN users u ON u.id=p.user_id WHERE p.activity_id=$1 ORDER BY p.joined_at`, [req.params.id] + ); + const groups = await db.query( + `SELECT id,name,qr_image_url AS "qrImageUrl",note FROM activity_groups WHERE activity_id=$1 AND status='approved' ORDER BY created_at`, [req.params.id] + ); + res.json({ ...normalizeActivity(row), participants: participants.rows, groups: groups.rows }); + }); + return router; +} + diff --git a/server/src/routes/participations.js b/server/src/routes/participations.js new file mode 100644 index 0000000..7526668 --- /dev/null +++ b/server/src/routes/participations.js @@ -0,0 +1,33 @@ +import { Router } from 'express'; +import { groupSchema, parse, purposeSchema } from '../lib/validation.js'; + +export function participationRoutes({ db, auth }) { + const router = Router({ mergeParams: true }); + router.put('/me', auth.userRequired, async (req, res) => { + const body = parse(purposeSchema, req, res); if (!body) return; + const capacity = await db.query(`SELECT capacity,(SELECT count(*) FROM participations WHERE activity_id=$1) count FROM activities WHERE id=$1 AND published=true`, [req.params.activityId]); + if (!capacity.rows[0]) return res.status(404).json({ error: '活动不存在' }); + const existing = await db.query('SELECT 1 FROM participations WHERE activity_id=$1 AND user_id=$2', [req.params.activityId, req.user.sub]); + if (!existing.rowCount && capacity.rows[0].capacity && Number(capacity.rows[0].count) >= capacity.rows[0].capacity) return res.status(409).json({ error: '活动人数已满' }); + const { rows: [item] } = await db.query( + `INSERT INTO participations(activity_id,user_id,purpose) VALUES($1,$2,$3) + ON CONFLICT(activity_id,user_id) DO UPDATE SET purpose=EXCLUDED.purpose,updated_at=now() + RETURNING activity_id AS "activityId",purpose,joined_at AS "joinedAt"`, [req.params.activityId, req.user.sub, body.purpose] + ); + res.json(item); + }); + router.delete('/me', auth.userRequired, async (req, res) => { + await db.query('DELETE FROM participations WHERE activity_id=$1 AND user_id=$2', [req.params.activityId, req.user.sub]); + res.status(204).end(); + }); + router.post('/groups', auth.userRequired, async (req, res) => { + const body = parse(groupSchema, req, res); if (!body) return; + const { rows: [group] } = await db.query( + `INSERT INTO activity_groups(activity_id,submitted_by,name,qr_image_url,note) VALUES($1,$2,$3,$4,$5) + RETURNING id,status,created_at AS "createdAt"`, [req.params.activityId, req.user.sub, body.name, body.qrImageUrl, body.note] + ); + res.status(201).json(group); + }); + return router; +} + diff --git a/server/src/routes/uploads.js b/server/src/routes/uploads.js new file mode 100644 index 0000000..4bf33f7 --- /dev/null +++ b/server/src/routes/uploads.js @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +function uploader(uploadDir) { + const storage = multer.diskStorage({ + destination: uploadDir, + filename: (_req, file, done) => done(null, `${Date.now()}-${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase()}`) + }); + return multer({ storage, limits: { fileSize: 8 * 1024 * 1024, files: 9 }, fileFilter: (_req, file, done) => done(null, /^image\/(jpeg|png|webp|gif)$/.test(file.mimetype)) }); +} + +export function uploadRoutes({ config, auth }) { + const router = Router(); const upload = uploader(config.uploadDir); + const response = (req, res) => res.status(201).json({ files: req.files.map((file) => ({ url: `${config.publicBaseUrl}/uploads/${file.filename}` })) }); + router.post('/', auth.userRequired, upload.array('files', 9), response); + router.post('/admin', auth.adminRequired, upload.array('files', 9), response); + return router; +} + diff --git a/server/test/activities.test.js b/server/test/activities.test.js new file mode 100644 index 0000000..423d5b6 --- /dev/null +++ b/server/test/activities.test.js @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { activityStatus, normalizeActivity } from '../src/lib/activities.js'; + +test('activityStatus distinguishes upcoming, active and ended', () => { + const now = new Date('2026-09-21T12:00:00Z'); + assert.equal(activityStatus('2026-09-22T12:00:00Z', null, now), 'upcoming'); + assert.equal(activityStatus('2026-09-21T11:00:00Z', '2026-09-21T13:00:00Z', now), 'active'); + assert.equal(activityStatus('2026-09-20T11:00:00Z', '2026-09-20T13:00:00Z', now), 'ended'); +}); +test('normalizeActivity supplies stable arrays and numeric count', () => { + const value = normalizeActivity({ startsAt: '2026-09-22T12:00:00Z', participantCount: '3' }, new Date('2026-09-21T12:00:00Z')); + assert.deepEqual(value.images, []); assert.deepEqual(value.tags, []); assert.equal(value.participantCount, 3); +}); + diff --git a/server/test/config.test.js b/server/test/config.test.js new file mode 100644 index 0000000..32105e9 --- /dev/null +++ b/server/test/config.test.js @@ -0,0 +1,7 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadConfig } from '../src/config.js'; + +test('production rejects missing secrets', () => assert.throws(() => loadConfig({ NODE_ENV: 'production' }), /Missing environment variables/)); +test('development permits local defaults', () => assert.equal(loadConfig({ NODE_ENV: 'development' }).allowDevLogin, true)); + diff --git a/server/test/wechat.test.js b/server/test/wechat.test.js new file mode 100644 index 0000000..d49f06b --- /dev/null +++ b/server/test/wechat.test.js @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { exchangeCode } from '../src/lib/wechat.js'; + +test('exchangeCode returns WeChat session', async () => { + const session = await exchangeCode({ code: 'abc', appId: 'id', appSecret: 'secret', fetchImpl: async (url) => { + assert.equal(url.searchParams.get('js_code'), 'abc'); return { ok: true, json: async () => ({ openid: 'openid-1' }) }; + }}); + assert.equal(session.openid, 'openid-1'); +}); +test('exchangeCode rejects WeChat API errors', async () => { + await assert.rejects(exchangeCode({ code: 'bad', appId: 'id', appSecret: 'secret', fetchImpl: async () => ({ ok: true, json: async () => ({ errcode: 40029, errmsg: 'invalid code' }) }) }), /invalid code/); +}); + diff --git a/uploadCloudFunction.sh b/uploadCloudFunction.sh new file mode 100644 index 0000000..df311b3 --- /dev/null +++ b/uploadCloudFunction.sh @@ -0,0 +1 @@ +${installPath} cloud functions deploy --e ${envId} --n quickstartFunctions --r --project ${projectPath} \ No newline at end of file