feat(订阅式软件提供): 包详情支持依赖列表与 README,打通产品官网数据链路
GVSDServiceServer:VenomPackage 增 Dependencies(JSON)/Readme(Text) 与迁移 0002,新增按包名取详情接口 /api/venom/project/<包名>/,定制页配置新增依赖编辑器与 README 编辑。 gvsds:新增 /API/Venom/Project/<包名> 公开只读接口,按 PublicSiteHost 反查产品并回源提供者;SubscriptionGroup 增 PublicSiteHost 字段(迁移 0055)。 Main.py:venom 子域加 SPA fallback,/project/<包名>/ 回退 index.html。
This commit is contained in:
1 parent
edecc6435a
commit
4bd5697cff
13 files changed
+394
-17
No files matched your search
+23
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-07 18:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('venom', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='venompackage',
|
||||
name='Dependencies',
|
||||
field=models.JSONField(blank=True, default=list, verbose_name='依赖列表'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='venompackage',
|
||||
name='Readme',
|
||||
field=models.TextField(default='', verbose_name='README(Markdown 原文)'),
|
||||
),
|
||||
]
|
||||
@@ -17,6 +17,10 @@ class VenomPackage(models.Model):
|
||||
PackageName = models.CharField(max_length=128, unique=True, verbose_name='包名(全局唯一)')
|
||||
PackageDesc = models.CharField(max_length=512, default='', verbose_name='包说明')
|
||||
ConfigText = models.TextField(default='', verbose_name='包配置内容')
|
||||
#: 依赖列表:[{'Name': 'xxx', 'Version': '^1.0.0'}];Version 可为空(无版本约束)
|
||||
Dependencies = models.JSONField(default=list, blank=True, verbose_name='依赖列表')
|
||||
#: README(Markdown 原文,由官网前端渲染)
|
||||
Readme = models.TextField(default='', verbose_name='README(Markdown 原文)')
|
||||
CreatorName = models.CharField(max_length=128, default='', verbose_name='创建人')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
@@ -11,4 +11,6 @@ urlpatterns = [
|
||||
path('packages/<uuid:package_uuid>/', views.PackageDetailView.as_view(), name='package_detail'),
|
||||
path('packages/<uuid:package_uuid>/config/', views.PackageConfigView.as_view(), name='package_config'),
|
||||
path('packages/<uuid:package_uuid>/releases/', views.PackageReleaseView.as_view(), name='package_releases'),
|
||||
# 官网按包名取详情(venom.gvsds.com/project/<包名>/)
|
||||
path('project/<path:package_name>/', views.PackageByNameView.as_view(), name='package_by_name'),
|
||||
]
|
||||
@@ -7,6 +7,7 @@
|
||||
POST /api/venom/packages/<uuid>/config/ 保存包配置
|
||||
GET /api/venom/packages/<uuid>/releases/ 发布历史
|
||||
POST /api/venom/packages/<uuid>/releases/ 发布新版本(写历史)
|
||||
GET /api/venom/project/<包名>/ 按包名取详情(官网调用:含 README 与依赖列表)
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -60,6 +61,42 @@ def _GetPackage(PackageUUID):
|
||||
PackageUUID=PackageUUID).first()
|
||||
|
||||
|
||||
def _NormalizeDependencies(value):
|
||||
"""依赖列表标准化为 [{'Name', 'Version'}];非法项丢弃、同名去重、限 200 条
|
||||
|
||||
兼容两种入参:字符串(仅包名)与对象 {'Name','Version'}(Version 可为空 = 无版本约束)。
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
rows, seen = [], set()
|
||||
for item in value[:200]:
|
||||
if isinstance(item, str):
|
||||
name, version = item.strip(), ''
|
||||
elif isinstance(item, dict):
|
||||
name = str(item.get('Name') or '').strip()
|
||||
version = str(item.get('Version') or '').strip()
|
||||
else:
|
||||
continue
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
rows.append({'Name': name[:128], 'Version': version[:64]})
|
||||
return rows
|
||||
|
||||
|
||||
def _DetailRow(package):
|
||||
"""包详情行(含 README/依赖/发布历史;官网与配置页共用)"""
|
||||
releases = list(package.releases.all())
|
||||
return {
|
||||
**_PackageRow(package),
|
||||
'ConfigText': package.ConfigText or '',
|
||||
'Readme': package.Readme or '',
|
||||
'Dependencies': package.Dependencies or [],
|
||||
'LatestVersion': releases[0].Version if releases else '',
|
||||
'Releases': [_ReleaseRow(r) for r in releases],
|
||||
}
|
||||
|
||||
|
||||
class PackageListView(APIView):
|
||||
"""包列表 / 新建包"""
|
||||
|
||||
@@ -83,26 +120,26 @@ class PackageListView(APIView):
|
||||
PackageName=name,
|
||||
PackageDesc=(request.data.get('PackageDesc') or '').strip()[:512],
|
||||
ConfigText=request.data.get('ConfigText') or '',
|
||||
Dependencies=_NormalizeDependencies(request.data.get('Dependencies')),
|
||||
Readme=request.data.get('Readme') or '',
|
||||
CreatorName=(request.data.get('CreatorName') or '').strip()[:128],
|
||||
)
|
||||
package = _GetPackage(package.PackageUUID)
|
||||
return _ok(_PackageRow(package), msg='软件包已创建')
|
||||
return _ok(_DetailRow(package), msg='软件包已创建')
|
||||
|
||||
|
||||
class PackageDetailView(APIView):
|
||||
"""包详情(配置页读取)"""
|
||||
"""包详情(配置页读取;含 README / 依赖列表 / 发布历史)"""
|
||||
|
||||
def get(self, request, package_uuid):
|
||||
package = _GetPackage(package_uuid)
|
||||
if package is None:
|
||||
return _err('软件包不存在', 404)
|
||||
row = _PackageRow(package)
|
||||
row['ConfigText'] = package.ConfigText or ''
|
||||
return _ok(row)
|
||||
return _ok(_DetailRow(package))
|
||||
|
||||
|
||||
class PackageConfigView(APIView):
|
||||
"""保存包配置"""
|
||||
"""保存包配置(说明 / 配置内容 / 依赖列表 / README)"""
|
||||
|
||||
def post(self, request, package_uuid):
|
||||
package = VenomPackage.objects.filter(PackageUUID=package_uuid).first()
|
||||
@@ -112,10 +149,13 @@ class PackageConfigView(APIView):
|
||||
package.PackageDesc = (request.data.get('PackageDesc') or '').strip()[:512]
|
||||
if 'ConfigText' in request.data:
|
||||
package.ConfigText = request.data.get('ConfigText') or ''
|
||||
package.save(update_fields=['PackageDesc', 'ConfigText', 'UpdateTime'])
|
||||
row = _PackageRow(_GetPackage(package.PackageUUID))
|
||||
row['ConfigText'] = package.ConfigText or ''
|
||||
return _ok(row, msg='配置已保存')
|
||||
if 'Dependencies' in request.data:
|
||||
package.Dependencies = _NormalizeDependencies(request.data.get('Dependencies'))
|
||||
if 'Readme' in request.data:
|
||||
package.Readme = request.data.get('Readme') or ''
|
||||
package.save(update_fields=[
|
||||
'PackageDesc', 'ConfigText', 'Dependencies', 'Readme', 'UpdateTime'])
|
||||
return _ok(_DetailRow(_GetPackage(package.PackageUUID)), msg='配置已保存')
|
||||
|
||||
|
||||
class PackageReleaseView(APIView):
|
||||
@@ -144,3 +184,18 @@ class PackageReleaseView(APIView):
|
||||
PublisherName=(request.data.get('PublisherName') or '').strip()[:128],
|
||||
)
|
||||
return _ok(_ReleaseRow(release), msg='已发布')
|
||||
|
||||
|
||||
class PackageByNameView(APIView):
|
||||
"""按包名取包详情——官网 `venom.gvsds.com/project/<包名>/` 的数据源
|
||||
|
||||
GET /api/venom/project/<PackageName>/
|
||||
"""
|
||||
|
||||
def get(self, request, package_name):
|
||||
name = str(package_name or '').strip().strip('/')
|
||||
package = VenomPackage.objects.annotate(release_count=Count('releases')).filter(
|
||||
PackageName=name).first()
|
||||
if package is None:
|
||||
return _err(f'包「{name}」不存在', 404, status=404)
|
||||
return _ok(_DetailRow(package))
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Box,
|
||||
Clock,
|
||||
Collection,
|
||||
Delete,
|
||||
Finished,
|
||||
Plus,
|
||||
Promotion,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import {
|
||||
venomApi,
|
||||
type RemoteContext,
|
||||
type VenomDependency,
|
||||
type VenomPackageItem,
|
||||
type VenomReleaseItem,
|
||||
} from './api'
|
||||
@@ -45,11 +47,40 @@ const detail = ref<VenomPackageItem | null>(null)
|
||||
const releases = ref<VenomReleaseItem[]>([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const createForm = reactive({ PackageName: '', PackageDesc: '', ConfigText: '' })
|
||||
const createForm = reactive({
|
||||
PackageName: '',
|
||||
PackageDesc: '',
|
||||
ConfigText: '',
|
||||
Readme: '',
|
||||
Dependencies: [] as VenomDependency[],
|
||||
})
|
||||
|
||||
const configForm = reactive({ PackageDesc: '', ConfigText: '' })
|
||||
const configForm = reactive({
|
||||
PackageDesc: '',
|
||||
ConfigText: '',
|
||||
Readme: '',
|
||||
Dependencies: [] as VenomDependency[],
|
||||
})
|
||||
const releaseForm = reactive({ Version: '', Notes: '' })
|
||||
|
||||
/** 空依赖行(依赖编辑器用) */
|
||||
function EmptyDependency(): VenomDependency {
|
||||
return { Name: '', Version: '' }
|
||||
}
|
||||
|
||||
/** 提交前清洗依赖:丢弃空包名、去空白、保序去重 */
|
||||
function CleanDependencies(rows: VenomDependency[]): VenomDependency[] {
|
||||
const seen = new Set<string>()
|
||||
const result: VenomDependency[] = []
|
||||
for (const row of rows) {
|
||||
const name = (row.Name || '').trim()
|
||||
if (!name || seen.has(name)) continue
|
||||
seen.add(name)
|
||||
result.push({ Name: name, Version: (row.Version || '').trim() })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 全部软件包的发布次数合计(列表概览用) */
|
||||
const totalReleases = computed(() =>
|
||||
packages.value.reduce((sum, p) => sum + (p.ReleaseCount || 0), 0),
|
||||
@@ -71,6 +102,8 @@ function openCreate(): void {
|
||||
createForm.PackageName = ''
|
||||
createForm.PackageDesc = ''
|
||||
createForm.ConfigText = ''
|
||||
createForm.Readme = ''
|
||||
createForm.Dependencies = [EmptyDependency()]
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -85,6 +118,8 @@ async function submitCreate(): Promise<void> {
|
||||
PackageName: createForm.PackageName.trim(),
|
||||
PackageDesc: createForm.PackageDesc,
|
||||
ConfigText: createForm.ConfigText,
|
||||
Readme: createForm.Readme,
|
||||
Dependencies: CleanDependencies(createForm.Dependencies),
|
||||
CreatorName: operatorName.value,
|
||||
})
|
||||
ElMessage.success('软件包已创建')
|
||||
@@ -109,6 +144,11 @@ async function openConfig(row: VenomPackageItem): Promise<void> {
|
||||
detail.value = d
|
||||
configForm.PackageDesc = d.PackageDesc || ''
|
||||
configForm.ConfigText = d.ConfigText || ''
|
||||
configForm.Readme = d.Readme || ''
|
||||
configForm.Dependencies = (d.Dependencies || []).map((dep) => ({
|
||||
Name: dep.Name || '',
|
||||
Version: dep.Version || '',
|
||||
}))
|
||||
releases.value = r.list || []
|
||||
releaseForm.Version = ''
|
||||
releaseForm.Notes = ''
|
||||
@@ -126,6 +166,17 @@ function backToList(): void {
|
||||
releases.value = []
|
||||
}
|
||||
|
||||
/** 依赖编辑器:追加空行 */
|
||||
function addDependency(rows: VenomDependency[]): void {
|
||||
rows.push(EmptyDependency())
|
||||
}
|
||||
|
||||
/** 依赖编辑器:删除指定行(至少保留一行占位) */
|
||||
function removeDependency(rows: VenomDependency[], index: number): void {
|
||||
rows.splice(index, 1)
|
||||
if (!rows.length) rows.push(EmptyDependency())
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
if (!detail.value) return
|
||||
saving.value = true
|
||||
@@ -133,6 +184,8 @@ async function saveConfig(): Promise<void> {
|
||||
detail.value = await venomApi.saveConfig(detail.value.PackageUUID, {
|
||||
PackageDesc: configForm.PackageDesc,
|
||||
ConfigText: configForm.ConfigText,
|
||||
Readme: configForm.Readme,
|
||||
Dependencies: CleanDependencies(configForm.Dependencies),
|
||||
})
|
||||
ElMessage.success('配置已保存')
|
||||
await loadPackages()
|
||||
@@ -299,6 +352,32 @@ onMounted(loadPackages)
|
||||
placeholder="包配置内容(如依赖、构建参数等)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="依赖列表">
|
||||
<div class="dep-editor">
|
||||
<div v-for="(dep, index) in configForm.Dependencies" :key="index" class="dep-row">
|
||||
<el-input v-model="dep.Name" placeholder="依赖包名" class="dep-name" />
|
||||
<el-input v-model="dep.Version" placeholder="版本约束,如 ^1.0.0(可留空)" class="dep-version" />
|
||||
<el-button
|
||||
:icon="Delete"
|
||||
circle
|
||||
plain
|
||||
type="danger"
|
||||
@click="removeDependency(configForm.Dependencies, index)"
|
||||
/>
|
||||
</div>
|
||||
<el-button :icon="Plus" text type="primary" @click="addDependency(configForm.Dependencies)">
|
||||
添加依赖
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="README(Markdown 原文,官网渲染)">
|
||||
<el-input
|
||||
v-model="configForm.Readme"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="# 标题 支持 Markdown 语法,将展示在 venom.gvsds.com/project/<包名>/"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" round :loading="saving" @click="saveConfig">保存配置</el-button>
|
||||
</el-form-item>
|
||||
@@ -359,7 +438,13 @@ onMounted(loadPackages)
|
||||
</template>
|
||||
|
||||
<!-- ==================== 新建软件包 ==================== -->
|
||||
<el-dialog v-model="dialogVisible" title="新建软件包" width="620px" :close-on-click-modal="false">
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="新建软件包"
|
||||
width="720px"
|
||||
top="6vh"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top" class="form">
|
||||
<el-form-item label="包名" required>
|
||||
<el-input v-model="createForm.PackageName" maxlength="128" placeholder="包名必须全局唯一" />
|
||||
@@ -370,6 +455,27 @@ onMounted(loadPackages)
|
||||
<el-form-item label="配置内容">
|
||||
<el-input v-model="createForm.ConfigText" type="textarea" :rows="5" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="依赖列表">
|
||||
<div class="dep-editor">
|
||||
<div v-for="(dep, index) in createForm.Dependencies" :key="index" class="dep-row">
|
||||
<el-input v-model="dep.Name" placeholder="依赖包名" class="dep-name" />
|
||||
<el-input v-model="dep.Version" placeholder="版本约束,如 ^1.0.0(可留空)" class="dep-version" />
|
||||
<el-button
|
||||
:icon="Delete"
|
||||
circle
|
||||
plain
|
||||
type="danger"
|
||||
@click="removeDependency(createForm.Dependencies, index)"
|
||||
/>
|
||||
</div>
|
||||
<el-button :icon="Plus" text type="primary" @click="addDependency(createForm.Dependencies)">
|
||||
添加依赖
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="README(Markdown 原文,可选)">
|
||||
<el-input v-model="createForm.Readme" type="textarea" :rows="6" placeholder="包文档,将展示在官网站点" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button round @click="dialogVisible = false">取消</el-button>
|
||||
@@ -560,4 +666,25 @@ onMounted(loadPackages)
|
||||
.form {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ==== 依赖编辑器 ==== */
|
||||
.dep-editor {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.dep-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.dep-name {
|
||||
flex: 1 1 42%;
|
||||
min-width: 0;
|
||||
}
|
||||
.dep-version {
|
||||
flex: 1 1 58%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,12 @@ const API_BASE = (() => {
|
||||
}
|
||||
})()
|
||||
|
||||
/** 依赖项:包名 + 版本约束(Version 为空 = 无约束) */
|
||||
export interface VenomDependency {
|
||||
Name: string
|
||||
Version: string
|
||||
}
|
||||
|
||||
export interface VenomPackageItem {
|
||||
PackageUUID: string
|
||||
PackageName: string
|
||||
@@ -36,6 +42,10 @@ export interface VenomPackageItem {
|
||||
UpdateTime: string
|
||||
ReleaseCount: number
|
||||
ConfigText?: string
|
||||
/** 依赖列表(详情接口返回) */
|
||||
Dependencies?: VenomDependency[]
|
||||
/** README(Markdown 原文,官网渲染) */
|
||||
Readme?: string
|
||||
}
|
||||
|
||||
/** 宿主注入的上下文(当前登录人/公司,用于「创建人」「发布人」) */
|
||||
@@ -72,16 +82,26 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
return payload.data as T
|
||||
}
|
||||
|
||||
/** 可写的包配置字段(新建/保存共用) */
|
||||
export interface VenomPackageConfig {
|
||||
PackageDesc: string
|
||||
ConfigText: string
|
||||
/** 依赖列表(名称 + 版本约束) */
|
||||
Dependencies: VenomDependency[]
|
||||
/** README(Markdown 原文) */
|
||||
Readme: string
|
||||
}
|
||||
|
||||
export const venomApi = {
|
||||
listPackages: (keyword = '') =>
|
||||
request<{ list: VenomPackageItem[]; total: number }>(
|
||||
'GET',
|
||||
`/packages/${keyword ? `?Keyword=${encodeURIComponent(keyword)}` : ''}`,
|
||||
),
|
||||
createPackage: (data: { PackageName: string; PackageDesc: string; ConfigText: string; CreatorName: string }) =>
|
||||
createPackage: (data: VenomPackageConfig & { PackageName: string; CreatorName: string }) =>
|
||||
request<VenomPackageItem>('POST', '/packages/', data),
|
||||
getPackage: (uuid: string) => request<VenomPackageItem>('GET', `/packages/${uuid}/`),
|
||||
saveConfig: (uuid: string, data: { PackageDesc: string; ConfigText: string }) =>
|
||||
saveConfig: (uuid: string, data: VenomPackageConfig) =>
|
||||
request<VenomPackageItem>('POST', `/packages/${uuid}/config/`, data),
|
||||
listReleases: (uuid: string) =>
|
||||
request<{ PackageName: string; list: VenomReleaseItem[]; total: number }>(
|
||||
|
||||
@@ -74,6 +74,10 @@ STATIC_SITES = {
|
||||
"venom.gvsds.com": Path("TempLate") / "Venom",
|
||||
}
|
||||
|
||||
# 单页应用站点:命中不到文件时回退 index.html,交由前端路由处理
|
||||
# (如 venom 官网的 /project/<包名>/ 详情页;带扩展名的资源请求仍按 404 处理)
|
||||
SPA_STATIC_SITES = {"venom.gvsds.com"}
|
||||
|
||||
LOG_CONFIG = {
|
||||
'disable_static_success': False,
|
||||
'disable_favicon': False,
|
||||
@@ -458,6 +462,12 @@ class SecurityMiddleware:
|
||||
# 目录则尝试 index.html
|
||||
if target.is_dir():
|
||||
target = target / 'index.html'
|
||||
# SPA 兜底:客户端路由(无扩展名,如 /project/<包名>/)回退站点入口
|
||||
if not target.is_file() and host in SPA_STATIC_SITES \
|
||||
and '.' not in os.path.basename(rel_path):
|
||||
spa_index = site_root / 'index.html'
|
||||
if spa_index.is_file():
|
||||
target = spa_index
|
||||
if not target.is_file():
|
||||
start_response('404 Not Found', [('Content-Type', 'text/html')])
|
||||
try:
|
||||
|
||||
@@ -74,6 +74,19 @@ def _ToStr(value):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _NormalizeHost(value):
|
||||
"""域名归一化:去掉 scheme / 路径 / 端口并转小写(用于按 Host 反查产品)"""
|
||||
text = str(value or '').strip().lower()
|
||||
if not text:
|
||||
return ''
|
||||
if '//' in text:
|
||||
text = text.split('//', 1)[1]
|
||||
text = text.split('/', 1)[0]
|
||||
if ':' in text:
|
||||
text = text.split(':', 1)[0]
|
||||
return text
|
||||
|
||||
|
||||
class SubscriptionManager:
|
||||
"""订阅组(产品)与组成员公司"""
|
||||
|
||||
@@ -564,12 +577,15 @@ class SubscriptionManager:
|
||||
# ==================== 产品定制页(提供者声明) + 我的订阅 ====================
|
||||
|
||||
@staticmethod
|
||||
def SetGroupRemotePage(GroupUUID, RemoteEntry, RemoteModule='default', TenantUUID=None):
|
||||
def SetGroupRemotePage(GroupUUID, RemoteEntry, RemoteModule='default',
|
||||
PublicSiteHost=None, TenantUUID=None):
|
||||
"""提供者子服务器声明本产品定制页的远端入口(幂等覆盖)
|
||||
|
||||
调用方已通过订阅访问令牌鉴权(令牌里的 SoftwareUUID 即 GroupUUID),
|
||||
因此这里只做范围与格式校验。
|
||||
|
||||
PublicSiteHost:产品官网域名(如 venom.gvsds.com),传 None 表示不改动。
|
||||
|
||||
Returns:
|
||||
(dict, error):{'GroupUUID', 'GroupName', 'RemoteEntry', 'RemoteModule'}
|
||||
"""
|
||||
@@ -584,7 +600,9 @@ class SubscriptionManager:
|
||||
module_name = str(RemoteModule or 'default').strip()[:64] or 'default'
|
||||
group.RemoteEntry = entry
|
||||
group.RemoteModule = module_name
|
||||
group.save(update_fields=['RemoteEntry', 'RemoteModule'])
|
||||
if PublicSiteHost is not None:
|
||||
group.PublicSiteHost = _NormalizeHost(PublicSiteHost)[:128]
|
||||
group.save(update_fields=['RemoteEntry', 'RemoteModule', 'PublicSiteHost'])
|
||||
# (重新)声明即失效本产品的资源缓存:入口 URL 通常不含内容版本号(如
|
||||
# /venom/remote-entry.js),若不主动清理,主服务在 TTL 内会一直回旧内容;
|
||||
# 清理后下一次请求立即回源,配合 SHA256 只换来真正变化的文件。
|
||||
@@ -601,6 +619,7 @@ class SubscriptionManager:
|
||||
'GroupName': group.GroupName,
|
||||
'RemoteEntry': group.RemoteEntry,
|
||||
'RemoteModule': group.RemoteModule,
|
||||
'PublicSiteHost': group.PublicSiteHost or '',
|
||||
'PurgedAssets': purged,
|
||||
'WarmError': warm_err or '',
|
||||
}, None
|
||||
@@ -659,6 +678,18 @@ class SubscriptionManager:
|
||||
return None, '该产品未声明有效的子服务器远端入口'
|
||||
return origin, None
|
||||
|
||||
@staticmethod
|
||||
def GroupByPublicHost(Host):
|
||||
"""按产品官网域名反查订阅组(提供者在声明定制页时登记的 PublicSiteHost)
|
||||
|
||||
用途:主服务承接产品官网域名(如 venom.gvsds.com)下的 `/API/...` 读取请求时,
|
||||
据此确定「是哪个产品」,再回源到对应的提供者子服务器。
|
||||
"""
|
||||
host = _NormalizeHost(Host)
|
||||
if not host:
|
||||
return None
|
||||
return SubscriptionGroup.objects.filter(PublicSiteHost=host).first()
|
||||
|
||||
@staticmethod
|
||||
def FetchRemoteAsset(GroupUUID, AssetPath):
|
||||
"""取远端资源:缓存内且未过期直接命中;否则回源并按 SHA256 判断是否变更
|
||||
|
||||
@@ -37,6 +37,10 @@ class SubscriptionGroup(QModel):
|
||||
# 宿主前端按 RemoteEntry 运行时加载远端组件并原生渲染(微前端,非 iframe)。
|
||||
RemoteEntry = models.CharField(max_length=512, default='', verbose_name='定制页远端入口URL')
|
||||
RemoteModule = models.CharField(max_length=64, default='default', verbose_name='定制页远端导出名')
|
||||
#: 产品官网域名(提供者声明,如 venom.gvsds.com):主服务据此把该域名的
|
||||
#: `/API/...` 读取请求反查到本产品(见 venom_site.GroupByPublicHost)。
|
||||
PublicSiteHost = models.CharField(max_length=128, default='', db_index=True,
|
||||
verbose_name='产品官网域名')
|
||||
CreatorUUID = UUIDField(null=True, blank=True, db_column='CreatorUUID', verbose_name='创建人UUID')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""模块:Venom 产品官网的公开只读接口(`venom.gvsds.com/API/Venom/*`)
|
||||
|
||||
定位:包数据由**提供者子服务器(GVSDServiceServer)**存储,主服务只负责「读」——
|
||||
按请求域名反查产品(`SubscriptionGroup.PublicSiteHost`),再回源到该产品的子服务器。
|
||||
|
||||
挂载:`gvsds/urls.py` → `path('API/Venom/', include('gvsds.apps.gerp.apps.subscription.venom_site'))`
|
||||
|
||||
GET /API/Venom/Project/<包名> 包详情(含 README / 依赖列表 / 发布历史)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from django.urls import path
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .manager import SubscriptionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 子服务器上 Venom 包接口的路径前缀
|
||||
PROVIDER_API_PREFIX = '/api/venom'
|
||||
#: 回源超时(秒)
|
||||
PROVIDER_TIMEOUT = 15
|
||||
|
||||
|
||||
class VenomProjectView(APIView):
|
||||
"""包详情——官网 `venom.gvsds.com/project/<包名>/` 的数据源
|
||||
|
||||
GET /API/Venom/Project/<PackageName>
|
||||
免登录:官网对所有人开放。
|
||||
"""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request, package_name):
|
||||
host = request.get_host()
|
||||
group = SubscriptionManager.GroupByPublicHost(host)
|
||||
if group is None:
|
||||
return Response(
|
||||
{'code': 1, 'msg': f'域名 {host} 未登记为任何产品的官网'}, status=404)
|
||||
|
||||
origin = SubscriptionManager.EntryOrigin(group.RemoteEntry)
|
||||
if origin is None:
|
||||
return Response({'code': 1, 'msg': '该产品未声明有效的子服务器远端入口'}, status=502)
|
||||
|
||||
name = str(package_name or '').strip().strip('/')
|
||||
if not name:
|
||||
return Response({'code': 1, 'msg': '缺少包名'}, status=400)
|
||||
|
||||
url = f'{origin}{PROVIDER_API_PREFIX}/project/{quote(name)}/'
|
||||
try:
|
||||
upstream = requests.get(url, timeout=PROVIDER_TIMEOUT)
|
||||
except Exception as exc:
|
||||
logger.error('Venom 读取提供者失败: %s (%s)', url, exc)
|
||||
return Response({'code': 1, 'msg': f'读取提供者失败:{exc}'}, status=502)
|
||||
|
||||
if upstream.status_code == 404:
|
||||
return Response({'code': 1, 'msg': f'包「{name}」不存在'}, status=404)
|
||||
if upstream.status_code != 200:
|
||||
logger.warning('Venom 提供者返回 %s: %s', upstream.status_code, url)
|
||||
return Response({'code': 1, 'msg': f'提供者返回 {upstream.status_code}'}, status=502)
|
||||
|
||||
try:
|
||||
payload = upstream.json()
|
||||
except Exception:
|
||||
return Response({'code': 1, 'msg': '提供者响应不是 JSON'}, status=502)
|
||||
return Response({'code': 0, 'data': payload.get('data')})
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('Project/<path:package_name>', VenomProjectView.as_view(), name='venom_public_project'),
|
||||
]
|
||||
@@ -420,6 +420,8 @@ class SubscriptionProviderPageSaveView(ELTAuthenticatedAPIView):
|
||||
getattr(principal, 'GroupUUID', None),
|
||||
request.data.get('RemoteEntry'),
|
||||
RemoteModule=request.data.get('RemoteModule') or 'default',
|
||||
# 可选:产品官网域名(如 venom.gvsds.com),供主服务承接该域名下的读取请求
|
||||
PublicSiteHost=request.data.get('PublicSiteHost'),
|
||||
)
|
||||
if err:
|
||||
return Response({'code': 1, 'msg': err}, status=400)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""订阅式软件提供(subscription):产品官网域名(SubscriptionGroup.PublicSiteHost)
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gerp', '0054_subscription_remote_asset'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subscriptiongroup',
|
||||
name='PublicSiteHost',
|
||||
field=models.CharField(
|
||||
db_index=True, default='', max_length=128, verbose_name='产品官网域名'),
|
||||
),
|
||||
]
|
||||
@@ -210,6 +210,9 @@ urlpatterns = [
|
||||
# 订阅式软件提供(订阅组=产品:提供者/消费者公司)——路由在子应用内(apps/subscription/urls.py)
|
||||
path('API/Subscription/', include('gvsds.apps.gerp.apps.subscription.urls')),
|
||||
|
||||
# Venom 产品官网(venom.gvsds.com)的公开只读接口——按域名反查产品并回源子服务器
|
||||
path('API/Venom/', include('gvsds.apps.gerp.apps.subscription.venom_site')),
|
||||
|
||||
# GERP系统 运维管理(仅系统管理员 admin:super)
|
||||
path('API/GERPSystem/Status', gerp_views.GERPSystemStatusView.as_view(), name='gerp_system_status'),
|
||||
path('API/GERPSystem/Schedules', gerp_views.GERPSystemSchedulesView.as_view(), name='gerp_system_schedules'),
|
||||
|
||||
Reference in new issue
Block a user