更新新功能

This commit is contained in:
taiyi 2026-05-18 14:24:55 +08:00
parent a8e6edf296
commit 7b50e2768b
23 changed files with 485 additions and 131 deletions

View File

@ -1,5 +1,5 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { apiRequest } from '../../utils/api'
@ -15,16 +15,18 @@ const editingId = ref(null)
const registrationVisible = ref(false)
const registrationLoading = ref(false)
const registrationDetail = ref(null)
const registrationFilters = reactive({
gender: '',
status: ''
})
const filteredRegistrations = ref([])
const filters = reactive({
keyword: '',
category: '',
publishState: ''
})
const form = reactive({
title: '',
description: '',
@ -42,6 +44,7 @@ const form = reactive({
require_audit: true,
is_published: false
})
const editForm = reactive({
title: '',
description: '',
@ -60,18 +63,24 @@ const editForm = reactive({
is_published: false
})
function mapActivity(item) {
return {
...item,
statusText: item.status === 0 ? '草稿' : item.status === 1 ? '报名中' : item.status === 4 ? '已结束' : '其他',
statusTagType: item.status === 1 ? 'success' : item.status === 4 ? 'info' : 'warning',
publishText: item.is_published ? '已发布' : '未发布',
publishTagType: item.is_published ? 'success' : 'info',
requireAuditText: item.require_audit ? '需审核' : '免审核',
shareUrlLinkStatusText: item.share_url_link ? '可用' : '不可用',
shareUrlLinkStatusType: item.share_url_link ? 'success' : 'danger'
}
}
async function loadActivities() {
loading.value = true
try {
const data = await apiRequest('/admin/activities')
allActivities.value = (Array.isArray(data) ? data : []).map((item) => ({
...item,
statusText: item.status === 0 ? '草稿' : item.status === 1 ? '报名中' : item.status === 4 ? '已结束' : '其他',
statusTagType: item.status === 1 ? 'success' : item.status === 4 ? 'info' : 'warning',
publishText: item.is_published ? '已发布' : '未发布',
publishTagType: item.is_published ? 'success' : 'info',
requireAuditText: item.require_audit ? '需审核' : '免审核'
}))
allActivities.value = (Array.isArray(data) ? data : []).map(mapActivity)
applyFilters()
} catch (error) {
ElMessage.error(error.message)
@ -84,8 +93,8 @@ async function loadActivities() {
function applyFilters() {
activities.value = allActivities.value.filter((item) => {
const keywordPassed = !filters.keyword || item.title.toLowerCase().includes(filters.keyword.toLowerCase())
const categoryPassed = filters.category === '' || item.category === filters.category
const keywordPassed = !filters.keyword || (item.title || '').toLowerCase().includes(filters.keyword.toLowerCase())
const categoryPassed = !filters.category || item.category === filters.category
const publishPassed = filters.publishState === '' || String(item.is_published) === String(filters.publishState)
return keywordPassed && categoryPassed && publishPassed
})
@ -127,7 +136,7 @@ async function createActivity() {
async function togglePublish(row) {
try {
await apiRequest(`/admin/activities/${row.id}/publish`, { method: 'PUT' })
ElMessage.success(row.is_published ? '已下架' : '已发布')
ElMessage.success(row.is_published ? '活动已下架' : '活动已发布')
await loadActivities()
} catch (error) {
ElMessage.error(error.message)
@ -136,7 +145,7 @@ async function togglePublish(row) {
async function deleteActivity(row) {
try {
await ElMessageBox.confirm(`确认删除活动${row.title}吗?`, '删除活动', {
await ElMessageBox.confirm(`确认删除活动${row.title}吗?`, '删除活动', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
@ -152,6 +161,23 @@ async function deleteActivity(row) {
}
}
function mapRegistration(item) {
const user = item.user || {}
return {
...item,
statusText: item.status === 1 ? '已报名' : item.status === 2 ? '已确认' : item.status === 3 ? '已取消' : item.status === 4 ? '已签到' : '其他',
user: {
...user,
nicknameText: user.nickname || '未填写昵称',
realNameText: user.real_name || '未填写',
genderText: user.gender === 1 ? '男' : user.gender === 2 ? '女' : '未填写',
cityText: user.city || '未填写',
educationText: user.education || '未填写',
companyText: user.job_company || '未填写'
}
}
}
async function openRegistrations(row) {
registrationVisible.value = true
registrationLoading.value = true
@ -159,21 +185,8 @@ async function openRegistrations(row) {
const data = await apiRequest(`/admin/activities/${row.id}/registrations`)
registrationDetail.value = {
...data,
registrations: (data.registrations || []).map((item) => ({
...item,
statusText: item.status === 1 ? '已报名' : item.status === 2 ? '已确认' : item.status === 3 ? '已取消' : item.status === 4 ? '已签到' : '其他',
user: {
...item.user,
nicknameText: item.user.nickname || '未填写昵称',
realNameText: item.user.real_name || '未填写',
genderText: item.user.gender === 1 ? '男' : item.user.gender === 2 ? '女' : '未填写',
cityText: item.user.city || '未填写',
educationText: item.user.education || '未填写',
companyText: item.user.job_company || '未填写'
}
}))
registrations: (data.registrations || []).map(mapRegistration)
}
applyRegistrationFilters()
} catch (error) {
ElMessage.error(error.message)
} finally {
@ -181,14 +194,14 @@ async function openRegistrations(row) {
}
}
function applyRegistrationFilters() {
const registrations = registrationDetail.value ? registrationDetail.value.registrations : []
filteredRegistrations.value = registrations.filter((item) => {
const filteredRegistrations = computed(() => {
const items = registrationDetail.value?.registrations || []
return items.filter((item) => {
const genderPassed = registrationFilters.gender === '' || String(item.user.gender) === String(registrationFilters.gender)
const statusPassed = registrationFilters.status === '' || String(item.status) === String(registrationFilters.status)
return genderPassed && statusPassed
})
}
})
function exportRegistrations() {
if (!filteredRegistrations.value.length) {
@ -260,26 +273,66 @@ async function updateActivity() {
}
}
async function copyShareLink(row) {
const value = row.share_link || row.share_path || row.share_token
async function writeClipboard(value, successMessage, emptyMessage) {
if (!value) {
ElMessage.warning('当前活动暂无分享链接')
ElMessage.warning(emptyMessage)
return
}
try {
await navigator.clipboard.writeText(value)
ElMessage.success('分享链接已复制')
ElMessage.success(successMessage)
} catch (error) {
ElMessage.error('复制失败,请手动复制')
}
}
function withAbsoluteUrl(url) {
if (!url) {
return ''
}
if (/^https?:\/\//i.test(url)) {
return url
}
const apiBase = import.meta.env.VITE_API_BASE_URL || 'https://ghxiangqin.com/api/v1'
const base = apiBase.replace(/\/api\/v1\/?$/, '')
return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`
}
async function copyShareLink(row) {
await writeClipboard(row.share_link || row.share_path || row.share_token, '分享页链接已复制', '当前活动暂无分享页链接')
}
async function copyShareApi(row) {
await writeClipboard(row.share_api_url || row.share_token, '接口链接已复制', '当前活动暂无接口链接')
}
async function copyUrlLink(row) {
await writeClipboard(
row.share_url_link,
'微信 URL Link 已复制',
row.share_url_link_error || '当前活动暂无微信 URL Link请确认小程序主体和发布环境可用'
)
}
async function refreshShareAssets(row) {
try {
await apiRequest(`/admin/activities/${row.id}/refresh-share-assets`, {
method: 'POST'
})
ElMessage.success('分享资源已重新生成')
await loadActivities()
} catch (error) {
ElMessage.error(error.message)
}
}
function previewQrCode(row) {
if (!row.share_qr_url) {
const url = withAbsoluteUrl(row.share_qr_url)
if (!url) {
ElMessage.warning('当前活动暂无二维码')
return
}
window.open(row.share_qr_url, '_blank')
window.open(url, '_blank')
}
function goCreatePage() {
@ -358,6 +411,11 @@ onMounted(loadActivities)
<el-table-column prop="match_deadline" label="匹配截止" min-width="180" />
<el-table-column prop="selection_limit" label="上限" width="90" />
<el-table-column prop="share_token" label="分享标识" min-width="140" />
<el-table-column label="URL Link" width="110">
<template #default="scope">
<el-tag :type="scope.row.shareUrlLinkStatusType">{{ scope.row.shareUrlLinkStatusText }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="scope">
<el-tag :type="scope.row.statusTagType">{{ scope.row.statusText }}</el-tag>
@ -366,23 +424,27 @@ onMounted(loadActivities)
<el-table-column prop="requireAuditText" label="报名要求" width="110" />
<el-table-column label="发布状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.publishTagType">
{{ scope.row.publishText }}
</el-tag>
<el-tag :type="scope.row.publishTagType">{{ scope.row.publishText }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<el-table-column label="操作" width="700" fixed="right">
<template #default="scope">
<div class="row-actions">
<el-button size="small" @click="openRegistrations(scope.row)">报名详情</el-button>
<el-button size="small" @click="copyShareLink(scope.row)">复制分享</el-button>
<el-button size="small" @click="copyShareLink(scope.row)">复制分享页</el-button>
<el-button size="small" type="primary" plain @click="copyUrlLink(scope.row)">复制 URL Link</el-button>
<el-button size="small" @click="copyShareApi(scope.row)">复制接口</el-button>
<el-button size="small" @click="previewQrCode(scope.row)">查看二维码</el-button>
<el-button size="small" @click="refreshShareAssets(scope.row)">重新生成分享</el-button>
<el-button size="small" @click="openEdit(scope.row)">编辑</el-button>
<el-button size="small" @click="togglePublish(scope.row)">
{{ scope.row.is_published ? '下架' : '发布' }}
</el-button>
<el-button size="small" type="danger" plain @click="deleteActivity(scope.row)">删除</el-button>
</div>
<div v-if="scope.row.share_url_link_error" class="share-error-text">
URL Link 错误{{ scope.row.share_url_link_error }}
</div>
</template>
</el-table-column>
</el-table>
@ -443,11 +505,11 @@ onMounted(loadActivities)
<div class="registration-toolbar">
<div class="filter-inline">
<el-select v-model="registrationFilters.gender" placeholder="性别筛选" clearable @change="applyRegistrationFilters">
<el-select v-model="registrationFilters.gender" placeholder="性别筛选" clearable>
<el-option label="男" :value="1" />
<el-option label="女" :value="2" />
</el-select>
<el-select v-model="registrationFilters.status" placeholder="状态筛选" clearable @change="applyRegistrationFilters">
<el-select v-model="registrationFilters.status" placeholder="状态筛选" clearable>
<el-option label="已报名" :value="1" />
<el-option label="已确认" :value="2" />
<el-option label="已取消" :value="3" />
@ -614,6 +676,14 @@ onMounted(loadActivities)
flex-wrap: wrap;
}
.share-error-text {
margin-top: 8px;
color: #b91c1c;
font-size: 12px;
line-height: 1.5;
word-break: break-word;
}
.dialog-form {
padding: 0;
box-shadow: none;

View File

@ -0,0 +1,25 @@
"""add activity share url link
Revision ID: 202605180001
Revises: 202605160002
Create Date: 2026-05-18 10:00:00
"""
from alembic import op
import sqlalchemy as sa
revision = "202605180001"
down_revision = "202605160002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("activities", sa.Column("share_url_link", sa.String(length=1000), nullable=True))
op.add_column("activities", sa.Column("share_url_link_error", sa.String(length=500), nullable=True))
def downgrade() -> None:
op.drop_column("activities", "share_url_link_error")
op.drop_column("activities", "share_url_link")

View File

@ -27,8 +27,10 @@ class Settings(BaseSettings):
wx_appid: str = ""
wx_secret: str = ""
wx_miniprogram_page_activity_detail: str = "pages/activity-detail/activity-detail"
wx_miniprogram_env_version: str = "release"
wx_template_audit_result: str = ""
wx_template_match_success: str = ""
public_site_url: str = ""
cos_region: str = ""
cos_secret_id: str = ""

View File

@ -3,11 +3,12 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.routers import api_router
from app.routers.activities import activity_share_landing
def create_app() -> FastAPI:
@ -85,6 +86,13 @@ def create_app() -> FastAPI:
static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.add_api_route(
"/share/activity/{share_token}",
activity_share_landing,
methods=["GET"],
response_class=HTMLResponse,
include_in_schema=False,
)
app.include_router(api_router, prefix=settings.api_v1_prefix)
return app

View File

@ -23,6 +23,8 @@ class Activity(TimestampMixin, Base):
selection_limit: Mapped[int] = mapped_column(SmallInteger, default=3, nullable=False)
share_token: Mapped[str | None] = mapped_column(String(64), unique=True)
share_qr_url: Mapped[str | None] = mapped_column(String(500))
share_url_link: Mapped[str | None] = mapped_column(String(1000))
share_url_link_error: Mapped[str | None] = mapped_column(String(500))
status: Mapped[int] = mapped_column(SmallInteger, default=0, nullable=False)
is_published: Mapped[int] = mapped_column(SmallInteger, default=0, nullable=False)
require_audit: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)

View File

@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@ -14,6 +15,9 @@ from app.schemas.activity_match import (
)
from app.services.activity_match_service import (
bind_activity_user,
build_activity_share_link,
build_activity_share_path,
build_activity_url_link,
build_activity_share_info,
cancel_activity_choice,
create_activity_choice,
@ -87,6 +91,66 @@ async def get_activity_by_share(
return {"code": 0, "message": "ok", "data": data}
@router.get("/share/{share_token}/landing", response_class=HTMLResponse, include_in_schema=False)
async def activity_share_landing(
share_token: str,
session: AsyncSession = Depends(get_db),
) -> HTMLResponse:
activity = await get_activity_by_share_token(session, share_token)
if activity is None or activity.is_published != 1:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Activity not found")
share_path = await build_activity_share_path(session, activity)
url_link = await build_activity_url_link(session, activity)
title = activity.title or "活动报名"
description = activity.description or "点击打开小程序查看活动详情并报名"
cover_image = activity.cover_image or ""
safe_title = title.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
safe_description = description.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br>")
open_hint = "请点击右上角,选择“打开小程序”或使用活动二维码进入。"
html = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{safe_title}</title>
<style>
body {{ margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(180deg, #f7f1e8 0%, #fffdf8 100%); color: #1f2937; }}
.wrap {{ max-width: 720px; margin: 0 auto; padding: 28px 20px 40px; }}
.card {{ background: rgba(255,255,255,0.92); border-radius: 24px; overflow: hidden; box-shadow: 0 18px 50px rgba(15,23,42,0.12); }}
.cover {{ width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: #e5e7eb; display: block; }}
.body {{ padding: 24px; }}
h1 {{ margin: 0 0 12px; font-size: 28px; line-height: 1.2; }}
p {{ margin: 0; color: #4b5563; line-height: 1.7; }}
.actions {{ display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; }}
.btn {{ display: inline-flex; align-items: center; justify-content: center; padding: 14px 18px; border-radius: 999px; text-decoration: none; font-weight: 600; }}
.btn-primary {{ background: #111827; color: #fff; }}
.btn-secondary {{ background: #f3f4f6; color: #111827; }}
.hint {{ margin-top: 18px; font-size: 14px; color: #6b7280; }}
.path {{ margin-top: 14px; padding: 12px 14px; border-radius: 14px; background: #f8fafc; font-size: 13px; color: #475569; word-break: break-all; }}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
{"<img class='cover' src='" + cover_image + "' alt='活动封面'>" if cover_image else "<div class='cover'></div>"}
<div class="body">
<h1>{safe_title}</h1>
<p>{safe_description}</p>
<div class="actions">
<a class="btn btn-primary" href="{url_link or 'weixin://'}">打开小程序</a>
<a class="btn btn-secondary" href="/static/uploads/activities/{activity.id}/share_qrcode.png" target="_blank" rel="noreferrer">查看活动二维码</a>
</div>
<div class="hint">{open_hint}</div>
<div class="path">小程序页面路径{share_path}</div>
</div>
</div>
</div>
</body>
</html>"""
return HTMLResponse(content=html)
@router.get("/{activity_id}")
async def get_activity(
activity_id: int,

View File

@ -1,14 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_admin
from app.schemas.activity import AdminActivityUpsertRequest
from app.services.activity_match_service import (
build_activity_share_api_url,
build_activity_share_link,
build_activity_share_path,
build_activity_url_link,
ensure_activity_share_qrcode,
ensure_activity_share_token,
refresh_activity_share_assets,
)
from app.services.admin_service import get_system_config_value
from app.services.activity_service import (
@ -32,6 +36,7 @@ async def get_activities(
_ = admin_payload
activities = await list_admin_activities(session)
api_base_url = await get_system_config_value(session, "api_base_url", "")
public_site_url = settings.public_site_url or api_base_url.replace("/api/v1", "").rstrip("/")
data = []
for item in activities:
item = await ensure_activity_share_token(session, item)
@ -58,12 +63,44 @@ async def get_activities(
"share_token": item.share_token,
"share_qr_url": item.share_qr_url,
"share_path": await build_activity_share_path(session, item),
"share_link": await build_activity_share_link(session, item, api_base_url=api_base_url),
"share_link": await build_activity_share_link(session, item, public_site_url=public_site_url),
"share_api_url": await build_activity_share_api_url(session, item, api_base_url=api_base_url),
"share_url_link": await build_activity_url_link(session, item),
"share_url_link_error": item.share_url_link_error,
}
)
return {"code": 0, "message": "ok", "data": data}
@router.post("/{activity_id}/refresh-share-assets")
async def refresh_share_assets(
activity_id: int,
admin_payload: dict = Depends(get_current_admin),
session: AsyncSession = Depends(get_db),
) -> dict:
_ = admin_payload
activity = await get_activity_by_id(session, activity_id)
if activity is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Activity not found")
activity = await refresh_activity_share_assets(session, activity)
api_base_url = await get_system_config_value(session, "api_base_url", "")
public_site_url = settings.public_site_url or api_base_url.replace("/api/v1", "").rstrip("/")
return {
"code": 0,
"message": "ok",
"data": {
"id": activity.id,
"share_qr_url": activity.share_qr_url,
"share_path": await build_activity_share_path(session, activity),
"share_link": await build_activity_share_link(session, activity, public_site_url=public_site_url),
"share_api_url": await build_activity_share_api_url(session, activity, api_base_url=api_base_url),
"share_url_link": activity.share_url_link,
"share_url_link_error": activity.share_url_link_error,
},
}
@router.post("")
async def create(
payload: AdminActivityUpsertRequest,

View File

@ -6,6 +6,7 @@ from app.dependencies import get_current_admin
from app.schemas.admin import AdminAuditRequest, AdminBanRequest
from app.services.notify_service import send_audit_result
from app.services.admin_service import audit_user, ban_user, get_user_detail, list_users
from app.utils.media import sanitize_user_media_payload
router = APIRouter()
@ -28,7 +29,7 @@ async def get_pending_users(
only_pending=only_pending,
)
data = [
{
sanitize_user_media_payload({
"id": user.id,
"nickname": user.nickname,
"real_name": user.real_name,
@ -39,7 +40,7 @@ async def get_pending_users(
"audit_status": user.audit_status,
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
"self_intro": user.self_intro,
}
})
for user in users
]
return {"code": 0, "message": "ok", "data": data}
@ -91,7 +92,7 @@ async def get_user(
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
data = {
data = sanitize_user_media_payload({
"id": user.id,
"nickname": user.nickname,
"real_name": user.real_name,
@ -110,7 +111,7 @@ async def get_user(
"audit_remark": user.audit_remark,
"is_active": user.is_active,
"ban_reason": user.ban_reason,
}
})
return {"code": 0, "message": "ok", "data": data}

View File

@ -6,6 +6,7 @@ from app.dependencies import get_current_user_id
from app.schemas.user import SubmitAuditResponse, UserProfileResponse, UserUpdateRequest
from app.services.match_service import get_public_user_info
from app.services.user_service import get_user_by_id, submit_user_audit, update_user_profile
from app.utils.media import sanitize_user_media_payload
router = APIRouter()
@ -19,7 +20,7 @@ async def get_me(
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
data = UserProfileResponse.model_validate(user).model_dump()
data = sanitize_user_media_payload(UserProfileResponse.model_validate(user).model_dump())
return {"code": 0, "message": "ok", "data": data}
@ -38,7 +39,7 @@ async def update_me(
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
data = UserProfileResponse.model_validate(updated_user).model_dump()
data = sanitize_user_media_payload(UserProfileResponse.model_validate(updated_user).model_dump())
return {"code": 0, "message": "ok", "data": data}

View File

@ -14,7 +14,8 @@ from app.models.registration import Registration
from app.models.user import User
from app.config import settings
from app.services.match_service import _level3_info
from app.utils.wx_api import generate_unlimited_qrcode
from app.utils.media import sanitize_user_media_payload
from app.utils.wx_api import generate_unlimited_qrcode, generate_url_link
ACTIVE_REGISTRATION_STATUSES = {1, 2, 4}
@ -63,6 +64,34 @@ def _list_item(user: User, *, selected_at: datetime | None, matched_at: datetime
}
def _candidate_item(user: User, selected: bool = False) -> dict:
return sanitize_user_media_payload({
"user_id": user.id,
"nickname": user.nickname,
"birth_year_range": _birth_year_range(user.birth_year),
"city": user.city,
"personality_tags": user.personality_tags if isinstance(user.personality_tags, list) else [],
"avatar_blur_url": user.avatar_blur_url,
"selected": selected,
})
def _list_item(user: User, *, selected_at: datetime | None, matched_at: datetime | None, matched: bool) -> dict:
return sanitize_user_media_payload({
"user_id": user.id,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"city": user.city,
"education": user.education,
"hobbies": user.hobbies if isinstance(user.hobbies, list) else [],
"selected_at": _format_datetime(selected_at),
"matched_at": _format_datetime(matched_at),
"match_status": "success" if matched else "pending",
"match_status_text": "ƥ<EFBFBD><EFBFBD>ɹ<EFBFBD>" if matched else "<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ӧ",
"can_view_detail": matched,
})
async def get_activity_by_id(session: AsyncSession, activity_id: int) -> Activity | None:
result = await session.execute(select(Activity).where(Activity.id == activity_id))
return result.scalar_one_or_none()
@ -502,7 +531,7 @@ async def build_activity_share_path(session: AsyncSession, activity: Activity) -
return f"pages/activity-detail/activity-detail?share_token={activity.share_token}"
async def build_activity_share_link(session: AsyncSession, activity: Activity, api_base_url: str | None = None) -> str:
async def build_activity_share_api_url(session: AsyncSession, activity: Activity, api_base_url: str | None = None) -> str:
activity = await ensure_activity_share_token(session, activity)
base_url = (api_base_url or "").rstrip("/")
if base_url:
@ -510,6 +539,42 @@ async def build_activity_share_link(session: AsyncSession, activity: Activity, a
return activity.share_token or ""
async def build_activity_share_link(session: AsyncSession, activity: Activity, public_site_url: str | None = None) -> str:
activity = await ensure_activity_share_token(session, activity)
base_url = (public_site_url or "").rstrip("/")
if base_url:
return f"{base_url}/share/activity/{activity.share_token}"
return f"/share/activity/{activity.share_token}"
async def build_activity_url_link(session: AsyncSession, activity: Activity) -> str | None:
activity = await ensure_activity_share_token(session, activity)
page = settings.wx_miniprogram_page_activity_detail or "pages/activity-detail/activity-detail"
if activity.share_url_link:
return activity.share_url_link
url_link, error_message = await generate_url_link(path=page, query=f"share_token={activity.share_token}")
activity.share_url_link = url_link
activity.share_url_link_error = error_message
session.add(activity)
await session.commit()
await session.refresh(activity)
return activity.share_url_link
async def refresh_activity_share_assets(session: AsyncSession, activity: Activity) -> Activity:
activity.share_qr_url = None
activity.share_url_link = None
activity.share_url_link_error = None
session.add(activity)
await session.commit()
await session.refresh(activity)
activity = await ensure_activity_share_qrcode(session, activity)
await build_activity_url_link(session, activity)
await session.refresh(activity)
return activity
async def get_bound_activity_by_id(session: AsyncSession, activity_id: int, current_user: User) -> Activity:
activity = await get_activity_by_id(session, activity_id)
if activity is None:

View File

@ -16,6 +16,7 @@ from app.services.activity_match_service import (
ensure_activity_share_token,
resolve_activity_stage,
)
from app.utils.media import sanitize_media_url
async def get_activity_by_id(session: AsyncSession, activity_id: int) -> Activity | None:
@ -69,7 +70,7 @@ async def _registered_preview(
{
"user_id": row.id,
"nickname": row.nickname,
"avatar_blur_url": row.avatar_blur_url,
"avatar_blur_url": sanitize_media_url(row.avatar_blur_url),
}
for row in rows
]

View File

@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.match import Match, MatchLike
from app.models.user import User
from app.services.admin_service import get_system_config_value
from app.utils.media import sanitize_user_media_payload
WEIGHTS = {
@ -34,7 +35,7 @@ def _birth_year_range(birth_year: int | None) -> str | None:
def _level1_info(user: User, *, score: float | None = None, reasons: list[str] | None = None) -> dict:
personality_tags = _safe_personality_tags(user.personality_tags)
return {
return sanitize_user_media_payload({
"user_id": user.id,
"nickname": user.nickname,
"birth_year_range": _birth_year_range(user.birth_year),
@ -43,21 +44,21 @@ def _level1_info(user: User, *, score: float | None = None, reasons: list[str] |
"avatar_blur_url": user.avatar_blur_url,
"match_score": score,
"match_reasons": reasons or [],
}
})
def _level2_info(user: User) -> dict:
return {
return sanitize_user_media_payload({
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"profile_images": user.profile_images if isinstance(user.profile_images, list) else [],
"education": user.education,
"hobbies": list(_safe_hobbies(user.hobbies)),
}
})
def _level3_info(user: User) -> dict:
return {
return sanitize_user_media_payload({
"user_id": user.id,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
@ -69,7 +70,7 @@ def _level3_info(user: User) -> dict:
"job_company": user.job_company,
"self_intro": user.self_intro,
"income_range": user.income_range,
}
})
def _age_score(user_a: User, user_b: User) -> float:
@ -397,14 +398,14 @@ async def list_my_matches(session: AsyncSession, current_user: User) -> list[dic
"match_status": "success",
"match_status_text": "匹配成功",
"fail_reason": None,
"other_user": {
"other_user": sanitize_user_media_payload({
"user_id": joined_user.id,
"nickname": joined_user.nickname,
"avatar_url": joined_user.avatar_url,
"education": joined_user.education,
"city": joined_user.city,
"hobbies": joined_user.hobbies,
},
}),
}
)
@ -419,14 +420,14 @@ async def list_my_matches(session: AsyncSession, current_user: User) -> list[dic
"match_status": "success" if reverse_like else "pending",
"match_status_text": "匹配成功" if reverse_like else "待回应",
"fail_reason": None if reverse_like else "等待对方回应",
"other_user": {
"other_user": sanitize_user_media_payload({
"user_id": joined_user.id,
"nickname": joined_user.nickname,
"avatar_url": joined_user.avatar_url,
"education": joined_user.education,
"city": joined_user.city,
"hobbies": joined_user.hobbies,
},
}),
}
)

View File

@ -0,0 +1,55 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
STATIC_ROOT = Path(__file__).resolve().parent.parent / "static"
def _local_static_file_exists(url: str) -> bool:
if not url.startswith("/static/"):
return True
relative_path = url.removeprefix("/static/")
file_path = STATIC_ROOT / Path(relative_path)
return file_path.is_file()
def sanitize_media_url(url: str | None) -> str | None:
if not url:
return None
if url.startswith("/static/") and not _local_static_file_exists(url):
return None
return url
def sanitize_media_list(urls: list[str] | None) -> list[str]:
if not isinstance(urls, list):
return []
items: list[str] = []
for item in urls:
if not isinstance(item, str):
continue
safe_url = sanitize_media_url(item)
if safe_url:
items.append(safe_url)
return items
def sanitize_user_media_payload(payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
avatar_url = sanitize_media_url(data.get("avatar_url"))
avatar_blur_url = sanitize_media_url(data.get("avatar_blur_url"))
profile_images = sanitize_media_list(data.get("profile_images"))
if not avatar_url and profile_images:
avatar_url = profile_images[0]
if not avatar_blur_url:
avatar_blur_url = avatar_url
if avatar_url and not profile_images:
profile_images = [avatar_url]
data["avatar_url"] = avatar_url
data["avatar_blur_url"] = avatar_blur_url
data["profile_images"] = profile_images
return data

View File

@ -44,7 +44,7 @@ async def generate_unlimited_qrcode(*, page: str, scene: str) -> bytes | None:
"page": page,
"scene": scene,
"check_path": False,
"env_version": "develop",
"env_version": settings.wx_miniprogram_env_version or "release",
}
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(url, json=payload)
@ -54,3 +54,28 @@ async def generate_unlimited_qrcode(*, page: str, scene: str) -> bytes | None:
if data.get("errcode"):
return None
return response.content
async def generate_url_link(*, path: str, query: str = "", env_version: str | None = None) -> tuple[str | None, str | None]:
if not settings.wx_appid or not settings.wx_secret:
return None, "微信小程序 AppID / Secret 未配置"
if settings.wx_appid.startswith("your_") or settings.wx_secret.startswith("your_"):
return None, "微信小程序 AppID / Secret 仍为占位配置"
access_token = await get_access_token()
if not access_token:
return None, "微信 access_token 获取失败"
url = f"https://api.weixin.qq.com/wxa/generate_urllink?access_token={access_token}"
payload = {
"path": path,
"query": query,
"is_expire": False,
"env_version": env_version or settings.wx_miniprogram_env_version or "release",
}
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(url, json=payload)
data = response.json()
if data.get("errcode"):
return None, data.get("errmsg") or f"微信接口错误:{data.get('errcode')}"
return data.get("url_link"), None

View File

@ -1,4 +1,5 @@
const request = require('../../utils/request')
const { resolveImageUrl } = require('../../utils/media')
Page({
data: {
@ -64,6 +65,7 @@ Page({
registered_preview: Array.isArray(activity.registered_preview)
? activity.registered_preview.map((item) => ({
...item,
avatar_blur_url: resolveImageUrl(item.avatar_blur_url || ''),
nicknameText: item.nickname || '用户'
}))
: []

View File

@ -1,4 +1,5 @@
const request = require('../../utils/request')
const { resolveImageUrl, resolveImageList } = require('../../utils/media')
Page({
data: {
@ -26,12 +27,14 @@ Page({
url: `/activities/${this.data.activityId}/matches/${this.data.targetUserId}/detail`
})
const otherUser = detail.other_user || {}
const profileImages = Array.isArray(otherUser.profile_images) ? otherUser.profile_images : []
const profileImages = resolveImageList(otherUser.profile_images)
this.setData({
userInfo: {
...otherUser,
avatar_url: resolveImageUrl(otherUser.avatar_url || ''),
avatar_blur_url: resolveImageUrl(otherUser.avatar_blur_url || ''),
profileImages,
previewImage: profileImages.length ? profileImages[0] : (otherUser.avatar_url || ''),
previewImage: profileImages.length ? profileImages[0] : resolveImageUrl(otherUser.avatar_url || ''),
nicknameText: otherUser.nickname || '匿名用户',
cityText: otherUser.city || '城市未知',
educationText: otherUser.education || '学历未知',

View File

@ -1,4 +1,5 @@
const request = require('../../utils/request')
const { resolveImageUrl } = require('../../utils/media')
const GENDER_MAP = {
1: 'male',
@ -230,6 +231,7 @@ Page({
const matchScoreLevel = hasMatchScore ? (safeMatchScore >= 80 ? 'high' : safeMatchScore >= 60 ? 'mid' : 'low') : 'none'
return {
...item,
avatar_blur_url: resolveImageUrl(item.avatar_blur_url || ''),
nicknameText: item.nickname || '匿名用户',
birthYearRangeText: item.birth_year_range || '年龄未知',
cityText: item.city || '城市未知',

View File

@ -101,4 +101,4 @@
</view>
</view>
</view>
</view>
</view>

View File

@ -1,4 +1,5 @@
const request = require('../../utils/request')
const { resolveImageUrl } = require('../../utils/media')
Page({
data: {
@ -36,7 +37,7 @@ Page({
normalizeMatch(item) {
const hobbies = Array.isArray(item.hobbies) ? item.hobbies : Array.isArray(item.other_user && item.other_user.hobbies) ? item.other_user.hobbies : []
const nickname = item.nickname || (item.other_user && item.other_user.nickname) || ''
const avatarUrl = item.avatar_url || (item.other_user && item.other_user.avatar_url) || ''
const avatarUrl = resolveImageUrl(item.avatar_url || (item.other_user && item.other_user.avatar_url) || '')
const city = item.city || (item.other_user && item.other_user.city) || ''
const education = item.education || (item.other_user && item.other_user.education) || ''
const statusMap = {

View File

@ -1,5 +1,6 @@
const request = require('../../utils/request')
const { getApiBaseUrl } = require('../../utils/constants')
const { resolveImageUrl, resolveImageList } = require('../../utils/media')
function addCacheVersion(url, version) {
if (!url) {
@ -9,36 +10,10 @@ function addCacheVersion(url, version) {
return `${url}${joiner}v=${encodeURIComponent(version)}`
}
function isLocalDevUrl(url) {
return /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?\//i.test(url)
}
function upgradeToHttpsIfNeeded(url) {
if (/^http:\/\//i.test(url) && !isLocalDevUrl(url)) {
return url.replace(/^http:\/\//i, 'https://')
}
return url
}
function resolveImageUrl(url) {
if (!url) {
return ''
}
if (/^https?:\/\//i.test(url)) {
return upgradeToHttpsIfNeeded(url)
}
const baseUrl = getApiBaseUrl().replace(/\/api\/v1\/?$/, '')
const resolved = url.startsWith('/') ? `${baseUrl}${url}` : `${baseUrl}/${url}`
return upgradeToHttpsIfNeeded(resolved)
}
function normalizeAvatarState(userInfo) {
const avatarUrl = resolveImageUrl(userInfo.avatar_url || '')
const avatarBlurUrl = resolveImageUrl(userInfo.avatar_blur_url || '')
const profileImages = Array.isArray(userInfo.profile_images)
? userInfo.profile_images.map((item) => resolveImageUrl(item)).filter(Boolean)
: []
const profileImages = resolveImageList(userInfo.profile_images)
return {
...userInfo,
@ -342,7 +317,7 @@ Page({
},
uploadAvatar(tempFilePath) {
wx.getFileInfo({
wx.getFileSystemManager().getFileInfo({
filePath: tempFilePath,
success: (info) => {
if (info.size > MAX_AVATAR_SIZE) {
@ -402,7 +377,7 @@ Page({
uploadProfileImage(tempFilePath) {
return new Promise((resolve) => {
wx.getFileInfo({
wx.getFileSystemManager().getFileInfo({
filePath: tempFilePath,
success: (info) => {
if (info.size > MAX_AVATAR_SIZE) {
@ -423,8 +398,7 @@ Page({
if (body.code !== 0) {
throw new Error(body.message || '上传失败')
}
const rawImages = Array.isArray(body.data.profile_images) ? body.data.profile_images : []
const profileImages = rawImages.map((item) => resolveImageUrl(item))
const profileImages = resolveImageList(body.data.profile_images)
const form = { ...this.data.form, profile_images: profileImages }
this.setData({ form, profileImages })
resolve(true)

View File

@ -1,5 +1,5 @@
const request = require('../../utils/request')
const { getApiBaseUrl } = require('../../utils/constants')
const { resolveImageUrl, resolveImageList } = require('../../utils/media')
function addCacheVersion(url, version) {
if (!url) {
@ -9,34 +9,8 @@ function addCacheVersion(url, version) {
return `${url}${joiner}v=${encodeURIComponent(version)}`
}
function isLocalDevUrl(url) {
return /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?\//i.test(url)
}
function upgradeToHttpsIfNeeded(url) {
if (/^http:\/\//i.test(url) && !isLocalDevUrl(url)) {
return url.replace(/^http:\/\//i, 'https://')
}
return url
}
function resolveImageUrl(url) {
if (!url) {
return ''
}
if (/^https?:\/\//i.test(url)) {
return upgradeToHttpsIfNeeded(url)
}
const baseUrl = getApiBaseUrl().replace(/\/api\/v1\/?$/, '')
const resolved = url.startsWith('/') ? `${baseUrl}${url}` : `${baseUrl}/${url}`
return upgradeToHttpsIfNeeded(resolved)
}
function normalizeAvatarState(userInfo) {
const profileImages = Array.isArray(userInfo.profile_images)
? userInfo.profile_images.map((item) => resolveImageUrl(item)).filter(Boolean)
: []
const profileImages = resolveImageList(userInfo.profile_images)
return {
...userInfo,
avatar_url: resolveImageUrl(userInfo.avatar_url || ''),

View File

@ -0,0 +1,39 @@
const { getApiBaseUrl } = require('./constants')
function isLocalDevUrl(url) {
return /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?\//i.test(url)
}
function upgradeToHttpsIfNeeded(url) {
if (/^http:\/\//i.test(url) && !isLocalDevUrl(url)) {
return url.replace(/^http:\/\//i, 'https://')
}
return url
}
function resolveImageUrl(url) {
if (!url) {
return ''
}
if (/^https?:\/\//i.test(url)) {
return upgradeToHttpsIfNeeded(url)
}
const baseUrl = getApiBaseUrl().replace(/\/api\/v1\/?$/, '')
const resolved = url.startsWith('/') ? `${baseUrl}${url}` : `${baseUrl}/${url}`
return upgradeToHttpsIfNeeded(resolved)
}
function resolveImageList(urls) {
if (!Array.isArray(urls)) {
return []
}
return urls.map((item) => resolveImageUrl(item)).filter(Boolean)
}
module.exports = {
resolveImageUrl,
resolveImageList,
upgradeToHttpsIfNeeded,
isLocalDevUrl
}

View File

@ -1,7 +1,9 @@
const { getApiBaseUrl } = require('./constants')
function isPublicRequest(url) {
return url === '/auth/wx-login' || url === '/auth/public-config'
return url === '/auth/wx-login'
|| url === '/auth/public-config'
|| /^\/activities\/share\/[^/]+$/i.test(url || '')
}
function normalizeError(message, detail) {