feat(schedule): add course table screens and navigation

Add complete schedule functionality including:
- Schedule screen with weekly course table view
- Course detail screen with transparent modal presentation
- New ScheduleStack navigator integrated into main tab bar
- Schedule service for API interactions
- Type definitions for course entities

Also includes bug fixes for group invite/request handlers
to include required groupId parameter.
This commit is contained in:
2026-03-12 08:38:14 +08:00
parent 21293644b8
commit 0a0cbacbcc
25 changed files with 3050 additions and 260 deletions

263
scripts/test_moderation.go Normal file
View File

@@ -0,0 +1,263 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const moderationSystemPrompt = `你是中文社区的内容审核助手,负责对"帖子标题、正文、配图"做联合审核。目标是平衡社区安全与正常交流必须拦截高风险违规内容但不要误伤正常玩梗、二创、吐槽和轻度调侃。请只输出指定JSON。
审核流程:
1) 先判断是否命中硬性违规;
2) 再判断语境(玩笑/自嘲/朋友间互动/作品讨论);
3) 做文图交叉判断(文本+图片合并理解);
4) 给出 approved 与简短 reason。
硬性违规(命中任一项必须 approved=false
A. 宣传对立与煽动撕裂:
- 明确煽动群体对立、地域对立、性别对立、民族宗教对立,鼓动仇恨、排斥、报复。
B. 严重人身攻击与网暴引导:
- 持续性侮辱贬损、羞辱人格、号召围攻/骚扰/挂人/线下冲突。
C. 开盒/人肉/隐私暴露:
- 故意公开、拼接、索取他人可识别隐私信息(姓名+联系方式、身份证号、住址、学校单位、车牌、定位轨迹等);
- 图片/截图中出现可识别隐私信息并伴随曝光意图,也按违规处理。
D. 其他高危违规:
- 违法犯罪、暴力威胁、极端仇恨、色情低俗、诈骗引流、恶意广告等。
放行规则(以下通常 approved=true
- 正常玩梗、表情包、谐音梗、二次创作、无恶意的吐槽;
- 非定向、轻度口语化吐槽(无明确攻击对象、无网暴号召、无隐私暴露);
- 对社会事件/作品的理性讨论、观点争论(即使语气尖锐,但未煽动对立或人身攻击)。
边界判定:
- 若只是"梗文化表达"且不指向现实伤害,优先通过;
- 若存在明确伤害意图(煽动、围攻、曝光隐私),必须拒绝;
- 对模糊内容不因个别粗口直接拒绝,需结合对象、意图、号召性和可执行性综合判断。
reason 要求:
- approved=false 时中文10-30字说明核心违规点
- approved=true 时reason 为空字符串。
输出格式(严格):
仅输出一行JSON对象不要Markdown不要额外解释
{"approved": true/false, "reason": "..."}`
type chatMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
}
type contentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
type chatCompletionsRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
EnableThinking *bool `json:"enable_thinking,omitempty"` // qwen3.5思考模式控制
ThinkingBudget *int `json:"thinking_budget,omitempty"` // 思考过程最大token数
ResponseFormat *responseFormatConfig `json:"response_format,omitempty"` // 响应格式
}
type responseFormatConfig struct {
Type string `json:"type"` // "text" or "json_object"
}
type chatCompletionsResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
func main() {
baseURL := flag.String("url", "https://api.littlelan.cn/", "API base URL")
apiKey := flag.String("key", "", "API key")
model := flag.String("model", "qwen3.5-plus", "Model name")
maxTokens := flag.Int("max-tokens", 220, "Max tokens for completion")
enableThinking := flag.Bool("enable-thinking", false, "Enable thinking mode for qwen3.5")
flag.Parse()
if *apiKey == "" {
fmt.Println("Error: API key is required. Use -key flag")
return
}
// 测试用例
testCases := []struct {
name string
content string
}{
{
name: "简单正常内容",
content: "帖子标题:今天天气真好\n帖子内容出门散步心情愉快",
},
{
name: "中等长度内容",
content: "帖子标题:分享我的学习经验\n帖子内容最近在学习Go语言发现这门语言真的很适合后端开发。并发处理特别方便goroutine和channel的设计非常优雅。有一起学习的小伙伴吗",
},
{
name: "较长内容",
content: "帖子标题:关于校园生活的一些思考\n帖子内容大学四年转眼就过去了回想起来有很多感慨。刚入学的时候什么都不懂现在感觉自己成长了很多。在这里想分享一些自己的经验希望能对学弟学妹们有所帮助。首先是学习方面一定要认真听课做好笔记。其次是社交方面多参加社团活动结交志同道合的朋友。最后是规划方面早点想清楚自己想做什么为之努力。",
},
}
client := &http.Client{Timeout: 120 * time.Second}
fmt.Println("============================================")
fmt.Printf("模型: %s\n", *model)
fmt.Printf("API URL: %s\n", *baseURL)
fmt.Printf("MaxTokens 设置: %d\n", *maxTokens)
fmt.Printf("EnableThinking: %v\n", *enableThinking)
fmt.Println("============================================")
for _, tc := range testCases {
fmt.Printf("\n========== 测试: %s ==========\n", tc.name)
fmt.Printf("内容长度: %d 字符\n", len(tc.content))
userPrompt := fmt.Sprintf("%s\n图片批次1/1本次仅提供当前批次图片", tc.content)
reqBody := chatCompletionsRequest{
Model: *model,
Messages: []chatMessage{
{Role: "system", Content: moderationSystemPrompt},
{Role: "user", Content: []contentPart{{Type: "text", Text: userPrompt}}},
},
Temperature: 0.1,
MaxTokens: *maxTokens,
}
// 设置思考模式
if !*enableThinking {
reqBody.EnableThinking = enableThinking
// 设置思考预算为0完全禁用思考
zero := 0
reqBody.ThinkingBudget = &zero
}
// 使用JSON输出格式
reqBody.ResponseFormat = &responseFormatConfig{Type: "json_object"}
data, err := json.Marshal(reqBody)
if err != nil {
fmt.Printf("Error marshaling request: %v\n", err)
continue
}
endpoint := strings.TrimRight(*baseURL, "/") + "/v1/chat/completions"
if strings.HasSuffix(strings.TrimRight(*baseURL, "/"), "/v1") {
endpoint = strings.TrimRight(*baseURL, "/") + "/chat/completions"
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
fmt.Printf("Error creating request: %v\n", err)
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+*apiKey)
start := time.Now()
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error sending request: %v\n", err)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Printf("Error reading response: %v\n", err)
continue
}
elapsed := time.Since(start)
if resp.StatusCode >= 400 {
fmt.Printf("API Error: status=%d, body=%s\n", resp.StatusCode, string(body))
continue
}
var parsed chatCompletionsResponse
if err := json.Unmarshal(body, &parsed); err != nil {
fmt.Printf("Error parsing response: %v\n", err)
fmt.Printf("Raw response: %s\n", string(body))
continue
}
if len(parsed.Choices) == 0 {
fmt.Println("No choices in response")
fmt.Printf("Raw response: %s\n", string(body))
continue
}
fmt.Printf("响应时间: %v\n", elapsed)
fmt.Printf("Finish Reason: %s\n", parsed.Choices[0].FinishReason)
fmt.Printf("Token使用情况:\n")
fmt.Printf(" - PromptTokens: %d\n", parsed.Usage.PromptTokens)
fmt.Printf(" - CompletionTokens: %d\n", parsed.Usage.CompletionTokens)
fmt.Printf(" - TotalTokens: %d\n", parsed.Usage.TotalTokens)
output := parsed.Choices[0].Message.Content
fmt.Printf("输出内容长度: %d 字符\n", len(output))
// 检查输出是否符合预期
if parsed.Usage.CompletionTokens > *maxTokens {
fmt.Printf("\n⚠ 警告: CompletionTokens (%d) 超过了 max_tokens 设置 (%d)!\n",
parsed.Usage.CompletionTokens, *maxTokens)
}
if len(output) > 500 {
fmt.Printf("\n⚠ 警告: 输出内容过长! 长度=%d\n", len(output))
fmt.Printf("前500字符:\n%s...\n", output[:min(500, len(output))])
} else {
fmt.Printf("输出内容: %s\n", output)
}
// 尝试解析JSON
extractJSONObject := func(raw string) string {
text := strings.TrimSpace(raw)
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start >= 0 && end > start {
return text[start : end+1]
}
return text
}
jsonStr := extractJSONObject(output)
var result struct {
Approved bool `json:"approved"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
fmt.Printf("\n⚠ 警告: 无法解析JSON输出: %v\n", err)
fmt.Printf("提取的JSON: %s\n", jsonStr)
} else {
fmt.Printf("\n✓ 解析成功: approved=%v, reason=\"%s\"\n", result.Approved, result.Reason)
}
}
fmt.Println("\n========== 测试完成 ==========")
}
func min(a, b int) int {
if a < b {
return a
}
return b
}